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