Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -2470,4 +2470,24 @@
<changeSet id="0107" author="benckx">
<comment>Indexes for latest move analysis aggregation and analyzed flag lookup</comment>
</changeSet>
<changeSet id="0108" author="benckx">
<comment>Add opt-in setting for offline opponent-flagged notifications</comment>
<addColumn tableName="user">
<column name="email_notification_enabled_opponent_flagged"
type="boolean"
defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0109" author="benckx">
<comment>Add opt-in setting for own flagged notifications</comment>
<addColumn tableName="user">
<column name="email_notification_enabled_user_flagged"
type="boolean"
defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
20 changes: 20 additions & 0 deletions webapp-dao-migration/src/main/resources/liquibase-changelog.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2697,4 +2697,24 @@
ON bot_game (analysis_end_time, analyzed_from_batch);
</sql>
</changeSet>
<changeSet id="0108" author="benckx">
<comment>Add opt-in setting for offline opponent-flagged notifications</comment>
<addColumn tableName="user">
<column name="email_notification_enabled_opponent_flagged"
type="boolean"
defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0109" author="benckx">
<comment>Add opt-in setting for own flagged notifications</comment>
<addColumn tableName="user">
<column name="email_notification_enabled_user_flagged"
type="boolean"
defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{{email_css}}
{{opponent}} flagged on time while you were offline.<br/>
Review the game at {{game_link}}<br/>
<br/>
{{update_mail_settings}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{{email_css}}
Hi {{username}},<br/>
<br/>
You lost your game on time (flagged): {{game_id}}.<br/>
Review the game at {{game_link}}<br/>
<br/>
{{update_mail_settings}}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ class PlayerVsPlayerGameServiceTest : ServiceTest() {
request = NotificationsSettingsDto(
newsletter = false,
opponentJoinedGame = enabled,
userFlagged = false,
opponentFlagged = false,
opponentPlayedMove = false,
opponentResigned = false,
opponentProposedDraw = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.'),
Expand All @@ -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;
Expand All @@ -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,
Expand Down