From 8f32eb958e3ed018c15c2681657ab492b465ab9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 19:00:08 +0000 Subject: [PATCH 1/6] Initial plan From f295c50d06161db9b3c03ad6b5db688e28a06e88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 19:06:58 +0000 Subject: [PATCH 2/6] Send admin email notification when a user flags Agent-Logs-Url: https://github.com/benckx/elephantchess/sessions/64c33041-18ef-4056-8497-199fd2943f7d Co-authored-by: benckx <8626080+benckx@users.noreply.github.com> --- .../servicelayer/services/MailService.kt | 15 ++++++++++ .../services/PlayerVsPlayerGameService.kt | 6 +++- .../user_flagged_notification.html | 5 ++++ .../servicelayer/services/MailRendererTest.kt | 30 +++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 webapp-service-layer/src/main/resources/mail_templates/user_flagged_notification.html create mode 100644 webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/MailRendererTest.kt 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 4913f1b6a..4e6bbd0b2 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 @@ -243,6 +243,21 @@ class MailService( ) } + suspend fun sendUserFlaggedNotification(gameId: String, userId: String, username: String) { + resolveAndSend( + recipient = ADMIN_GMAIL_EMAIL, + subject = "user flagged", + templateName = "user_flagged_notification", + resolvers = listOf( + SimpleValueTagResolver("game_id", gameId), + SimpleValueTagResolver("user_id", userId), + SimpleValueTagResolver("username", username), + GameLinkTagResolver(webHost, gameId), + ), + skipRecipientValidityCheck = 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 aa6b09ede..8e5d69a2c 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 @@ -640,7 +640,11 @@ class PlayerVsPlayerGameService( winnerColor = color.reverse(), updateRatingsCallback = updateRatingsCallback ) - // TODO: send email when offline? + mailService.sendUserFlaggedNotification( + gameId = gameId, + userId = userId, + username = userCache.fetchUsernameOrDefault(userId), + ) } } 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..8ccf8fee9 --- /dev/null +++ b/webapp-service-layer/src/main/resources/mail_templates/user_flagged_notification.html @@ -0,0 +1,5 @@ +{{email_css}} +user flagged in game: {{game_id}}
+userId: {{user_id}}
+username: {{username}}
+game link: {{game_link}} 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..836297d22 --- /dev/null +++ b/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/MailRendererTest.kt @@ -0,0 +1,30 @@ +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("user_id", "user-456"), + SimpleValueTagResolver("username", "alice"), + SimpleValueTagResolver("game_link", "https://elephantchess.test/g/game-123"), + ) + ) + + assertTrue(html.contains("user flagged in game: game-123")) + assertTrue(html.contains("userId: user-456")) + assertTrue(html.contains("username: alice")) + assertTrue(html.contains("game link: https://elephantchess.test/g/game-123")) + } +} From 3939ca08995c609540206bcbbef6819bcb3b64d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:13:42 +0000 Subject: [PATCH 3/6] Notify flagged player instead of admin Agent-Logs-Url: https://github.com/benckx/elephantchess/sessions/1607d2e9-cbf3-430e-8977-17112c08f0de Co-authored-by: benckx <8626080+benckx@users.noreply.github.com> --- .../servicelayer/services/MailService.kt | 8 +++----- .../services/PlayerVsPlayerGameService.kt | 15 ++++++++++----- .../mail_templates/user_flagged_notification.html | 10 ++++++---- .../servicelayer/services/MailRendererTest.kt | 9 ++++----- 4 files changed, 23 insertions(+), 19 deletions(-) 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 4e6bbd0b2..b725fbe35 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 @@ -243,18 +243,16 @@ class MailService( ) } - suspend fun sendUserFlaggedNotification(gameId: String, userId: String, username: String) { + suspend fun sendUserFlaggedNotification(recipient: String, gameId: String, username: String) { resolveAndSend( - recipient = ADMIN_GMAIL_EMAIL, - subject = "user flagged", + recipient = recipient, + subject = "You lost on time", templateName = "user_flagged_notification", resolvers = listOf( SimpleValueTagResolver("game_id", gameId), - SimpleValueTagResolver("user_id", userId), SimpleValueTagResolver("username", username), GameLinkTagResolver(webHost, gameId), ), - skipRecipientValidityCheck = true ) } 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 8e5d69a2c..e84a3fb6a 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 @@ -640,11 +640,16 @@ class PlayerVsPlayerGameService( winnerColor = color.reverse(), updateRatingsCallback = updateRatingsCallback ) - mailService.sendUserFlaggedNotification( - gameId = gameId, - userId = userId, - username = userCache.fetchUsernameOrDefault(userId), - ) + 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 = userCache.fetchUsernameOrDefault(userId), + ) + } } } 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 index 8ccf8fee9..cd642293e 100644 --- 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 @@ -1,5 +1,7 @@ {{email_css}} -user flagged in game: {{game_id}}
-userId: {{user_id}}
-username: {{username}}
-game link: {{game_link}} +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/servicelayer/services/MailRendererTest.kt b/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/MailRendererTest.kt index 836297d22..9e91353a4 100644 --- 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 @@ -16,15 +16,14 @@ class MailRendererTest { specificTagResolvers = listOf( SimpleValueTagResolver("email_css", ""), SimpleValueTagResolver("game_id", "game-123"), - SimpleValueTagResolver("user_id", "user-456"), SimpleValueTagResolver("username", "alice"), SimpleValueTagResolver("game_link", "https://elephantchess.test/g/game-123"), + SimpleValueTagResolver("update_mail_settings", ""), ) ) - assertTrue(html.contains("user flagged in game: game-123")) - assertTrue(html.contains("userId: user-456")) - assertTrue(html.contains("username: alice")) - assertTrue(html.contains("game link: https://elephantchess.test/g/game-123")) + 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")) } } From 781ec00c19c814e6a075164118413f5e93573e68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:22:44 +0000 Subject: [PATCH 4/6] Add offline opponent-flagged notification setting and emails Agent-Logs-Url: https://github.com/benckx/elephantchess/sessions/39dc1681-73c5-47cd-82b3-8a92e21ea7bc Co-authored-by: benckx <8626080+benckx@users.noreply.github.com> --- .../liquibase-changelog-generation.xml | 11 +++++++++++ .../src/main/resources/liquibase-changelog.xml | 11 +++++++++++ .../db/model/NotificationsSettingsRecord.kt | 1 + .../services/PlayerVsPlayerGameDaoService.kt | 17 +++++++++++++++++ .../db/services/UserDaoService.kt | 6 ++++++ .../dto/user/NotificationsSettingsDto.kt | 1 + .../servicelayer/services/MailService.kt | 13 +++++++++++++ .../services/PlayerVsPlayerGameService.kt | 18 +++++++++++++++++- .../servicelayer/services/UserService.kt | 2 ++ .../opponent_flagged_while_offline.html | 5 +++++ .../db/services/UserDaoServiceTest.kt | 2 ++ .../servicelayer/services/MailRendererTest.kt | 16 ++++++++++++++++ .../notification-settings-widget.js | 3 +++ 13 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 webapp-service-layer/src/main/resources/mail_templates/opponent_flagged_while_offline.html 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 dea36f17c..4a5229426 100644 --- a/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml +++ b/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml @@ -2283,4 +2283,15 @@ + + + Add opt-in setting for offline opponent-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 2f376dedb..824ff9bc8 100644 --- a/webapp-dao-migration/src/main/resources/liquibase-changelog.xml +++ b/webapp-dao-migration/src/main/resources/liquibase-changelog.xml @@ -2456,4 +2456,15 @@ + + + Add opt-in setting for offline opponent-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..a23b4b002 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,7 @@ package io.elephantchess.db.model data class NotificationsSettingsRecord( val newsletter: Boolean, val opponentJoinedGame: 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 b485e9d24..8089f68af 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 @@ -578,6 +578,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 509fa4c32..80fc48a95 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 @@ -65,6 +65,7 @@ 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_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) @@ -107,6 +108,7 @@ 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_OPPONENT_FLAGGED, USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_PLAYED_MOVE, USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_RESIGNED, USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_PROPOSED_DRAW, @@ -120,6 +122,7 @@ class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) { NotificationsSettingsRecord( newsletter = userRecord.emailNotificationEnabledNewsletter, opponentJoinedGame = userRecord.emailNotificationEnabledUserJoinedGame, + opponentFlagged = userRecord.emailNotificationEnabledOpponentFlagged, opponentPlayedMove = userRecord.emailNotificationEnabledOpponentPlayedMove, opponentResigned = userRecord.emailNotificationEnabledOpponentResigned, opponentProposedDraw = userRecord.emailNotificationEnabledOpponentProposedDraw, @@ -133,6 +136,7 @@ class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) { userId: String, newsletter: Boolean, opponentJoinedGame: Boolean, + opponentFlagged: Boolean, opponentPlayedMove: Boolean, opponentResigned: Boolean, opponentProposedDraw: Boolean, @@ -145,6 +149,7 @@ 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_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) @@ -173,6 +178,7 @@ 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_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..7f93ee31f 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,7 @@ package io.elephantchess.servicelayer.dto.user data class NotificationsSettingsDto( val newsletter: Boolean, val opponentJoinedGame: 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 b725fbe35..f319ec743 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 @@ -256,6 +256,19 @@ class MailService( ) } + suspend fun sendOpponentFlaggedWhileOffline(recipient: String, opponent: String, gameId: String) { + resolveAndSend( + 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 e84a3fb6a..87173b00c 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 @@ -634,6 +634,7 @@ 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, @@ -647,9 +648,24 @@ class PlayerVsPlayerGameService( mailService.sendUserFlaggedNotification( recipient = email, gameId = gameId, - username = userCache.fetchUsernameOrDefault(userId), + 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 b7b8c0a47..af8af8f11 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 @@ -365,6 +365,7 @@ class UserService( return NotificationsSettingsDto( newsletter = record.newsletter, opponentJoinedGame = record.opponentJoinedGame, + opponentFlagged = record.opponentFlagged, opponentPlayedMove = record.opponentPlayedMove, opponentResigned = record.opponentResigned, opponentProposedDraw = record.opponentProposedDraw, @@ -378,6 +379,7 @@ class UserService( userId = userId, newsletter = request.newsletter, opponentJoinedGame = request.opponentJoinedGame, + 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/test/kotlin/io/elephantchess/db/services/UserDaoServiceTest.kt b/webapp-service-layer/src/test/kotlin/io/elephantchess/db/services/UserDaoServiceTest.kt index 6eb8b70a1..428aaea13 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 @@ -53,6 +53,7 @@ class UserDaoServiceTest : ServiceTest() { assertFalse(user.emailNotificationEnabledNewsletter) assertFalse(user.emailNotificationEnabledUserJoinedGame) + assertFalse(user.emailNotificationEnabledOpponentFlagged) assertFalse(user.emailNotificationEnabledOpponentPlayedMove) assertFalse(user.emailNotificationEnabledOpponentResigned) assertFalse(user.emailNotificationEnabledOpponentProposedDraw) @@ -67,6 +68,7 @@ class UserDaoServiceTest : ServiceTest() { assertTrue(user.emailNotificationEnabledNewsletter) assertTrue(user.emailNotificationEnabledUserJoinedGame) + 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 index 9e91353a4..dffc64b0e 100644 --- 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 @@ -26,4 +26,20 @@ class MailRendererTest { 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/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..4b9378c35 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,7 @@ 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('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 +142,7 @@ class NotificationSettingsWidget { getAndHandle(NOTIFICATIONS_API, json => { this.#findSettingById('newsletter').isChecked = json.newsletter; this.#findSettingById('opponent-joined-game').isChecked = json.opponentJoinedGame; + 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 +158,7 @@ class NotificationSettingsWidget { let body = { 'newsletter': this.#findSettingById('newsletter').isChecked, 'opponentJoinedGame': this.#findSettingById('opponent-joined-game').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, From 21910c02ef6b8c04efb0685af5a878f0a647a63e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:20:41 +0000 Subject: [PATCH 5/6] Add separate user-flagged notification setting and check --- .../main/resources/liquibase-changelog-generation.xml | 10 ++++++++++ .../src/main/resources/liquibase-changelog.xml | 10 ++++++++++ .../db/model/NotificationsSettingsRecord.kt | 1 + .../io/elephantchess/db/services/UserDaoService.kt | 6 ++++++ .../servicelayer/dto/user/NotificationsSettingsDto.kt | 1 + .../servicelayer/services/PlayerVsPlayerGameService.kt | 5 +++++ .../elephantchess/servicelayer/services/UserService.kt | 2 ++ .../io/elephantchess/db/services/UserDaoServiceTest.kt | 2 ++ .../services/PlayerVsPlayerGameServiceTest.kt | 1 + .../js/user-settings/notification-settings-widget.js | 3 +++ 10 files changed, 41 insertions(+) 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 dbc8271b9..30f337915 100644 --- a/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml +++ b/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml @@ -2480,4 +2480,14 @@
+ + 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 4d21351fc..07e06f2da 100644 --- a/webapp-dao-migration/src/main/resources/liquibase-changelog.xml +++ b/webapp-dao-migration/src/main/resources/liquibase-changelog.xml @@ -2707,4 +2707,14 @@
+ + 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 a23b4b002..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,7 @@ 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, 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 c9a12913f..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,7 @@ 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) @@ -111,6 +112,7 @@ 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, @@ -125,6 +127,7 @@ 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, @@ -139,6 +142,7 @@ class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) { userId: String, newsletter: Boolean, opponentJoinedGame: Boolean, + userFlagged: Boolean, opponentFlagged: Boolean, opponentPlayedMove: Boolean, opponentResigned: Boolean, @@ -152,6 +156,7 @@ 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) @@ -181,6 +186,7 @@ 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) 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 7f93ee31f..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,7 @@ 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, 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 3c1f20d71..6d58deb9b 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 @@ -812,9 +812,14 @@ class PlayerVsPlayerGameService( winnerColor = color.reverse(), updateRatingsCallback = updateRatingsCallback ) + val notificationSettings = userDaoService.fetchNotificationSettings(userId) 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 if (notificationSettings?.userFlagged != true) { + logger.info { + "not sending 'user flagged' notification for $userId in $gameId because notification is disabled" + } } else { mailService.sendUserFlaggedNotification( recipient = email, 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 d9764c9b5..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,7 @@ class UserService( return NotificationsSettingsDto( newsletter = record.newsletter, opponentJoinedGame = record.opponentJoinedGame, + userFlagged = record.userFlagged, opponentFlagged = record.opponentFlagged, opponentPlayedMove = record.opponentPlayedMove, opponentResigned = record.opponentResigned, @@ -401,6 +402,7 @@ class UserService( userId = userId, newsletter = request.newsletter, opponentJoinedGame = request.opponentJoinedGame, + userFlagged = request.userFlagged, opponentFlagged = request.opponentFlagged, opponentPlayedMove = request.opponentPlayedMove, opponentResigned = request.opponentResigned, 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 7602fc820..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,7 @@ class UserDaoServiceTest : ServiceTest() { assertFalse(user.emailNotificationEnabledNewsletter) assertFalse(user.emailNotificationEnabledUserJoinedGame) + assertFalse(user.emailNotificationEnabledUserFlagged) assertFalse(user.emailNotificationEnabledOpponentFlagged) assertFalse(user.emailNotificationEnabledOpponentPlayedMove) assertFalse(user.emailNotificationEnabledOpponentResigned) @@ -107,6 +108,7 @@ class UserDaoServiceTest : ServiceTest() { assertTrue(user.emailNotificationEnabledNewsletter) assertTrue(user.emailNotificationEnabledUserJoinedGame) + assertTrue(user.emailNotificationEnabledUserFlagged) assertTrue(user.emailNotificationEnabledOpponentFlagged) assertTrue(user.emailNotificationEnabledOpponentPlayedMove) assertTrue(user.emailNotificationEnabledOpponentResigned) 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 bd21a34f6..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,7 @@ class PlayerVsPlayerGameServiceTest : ServiceTest() { request = NotificationsSettingsDto( newsletter = false, opponentJoinedGame = enabled, + userFlagged = false, opponentFlagged = false, opponentPlayedMove = false, opponentResigned = 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 4b9378c35..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,7 @@ 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.'), @@ -142,6 +143,7 @@ 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; @@ -158,6 +160,7 @@ 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, From ef62aacd74c1b538f6054a13c4c3eb02aba49dc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:22:50 +0000 Subject: [PATCH 6/6] Avoid user email lookup when self-flagged notifications are disabled --- .../services/PlayerVsPlayerGameService.kt | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) 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 6d58deb9b..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 @@ -813,19 +813,21 @@ class PlayerVsPlayerGameService( updateRatingsCallback = updateRatingsCallback ) val notificationSettings = userDaoService.fetchNotificationSettings(userId) - 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 if (notificationSettings?.userFlagged != true) { + if (notificationSettings?.userFlagged != true) { logger.info { "not sending 'user flagged' notification for $userId in $gameId because notification is disabled" } } else { - mailService.sendUserFlaggedNotification( - recipient = email, - gameId = gameId, - username = username, - ) + 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