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,