diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/NotificationService.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/NotificationService.java
index d0b4991..5faa8f9 100644
--- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/NotificationService.java
+++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/NotificationService.java
@@ -50,7 +50,7 @@ public class NotificationService {
""" + ChatConstants.HELP_SUGGESTION;
private static final int GREETING_NOTIFICATION_ORDER = Ordered.HIGHEST_PRECEDENCE;
- private static final int USER_INTERACTION_NOTIFICATION_ORDER = GREETING_NOTIFICATION_ORDER + 1000;
+ static final int USER_INTERACTION_NOTIFICATION_ORDER = GREETING_NOTIFICATION_ORDER + 1000;
private static final int DAILY_RANKING_NOTIFICATION_ORDER = 0;
private final CustomTelegramClient customTelegramClient;
diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java
new file mode 100644
index 0000000..a88ad06
--- /dev/null
+++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationService.java
@@ -0,0 +1,81 @@
+package dev.rubasace.linkedin.games.ldrbot.chat;
+
+import dev.rubasace.linkedin.games.ldrbot.configuration.ExecutorsConfiguration;
+import dev.rubasace.linkedin.games.ldrbot.session.GameSessionRegistrationEvent;
+import dev.rubasace.linkedin.games.ldrbot.session.GameSessionRepository;
+import dev.rubasace.linkedin.games.ldrbot.util.FormatUtils;
+import org.springframework.core.annotation.Order;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Sends a notification to the group chat when a user establishes a new record
+ * (i.e., the fastest time ever registered for a given game in the group).
+ */
+@Component
+public class RecordNotificationService {
+
+ private static final String NEW_RECORD_MESSAGE_TEMPLATE = "\uD83C\uDFC6 New %s record!\n%s set a new group record with a time of %s! \uD83C\uDF89";
+ private static final String RECORD_BROKEN_MESSAGE_TEMPLATE = "\uD83C\uDFC6 New %s record!\n%s set a new group record with a time of %s (previous record: %s)! \uD83C\uDF89";
+
+ private static final int RECORD_NOTIFICATION_ORDER = NotificationService.USER_INTERACTION_NOTIFICATION_ORDER + 1;
+
+ private final CustomTelegramClient customTelegramClient;
+ private final GameSessionRepository gameSessionRepository;
+
+ RecordNotificationService(final CustomTelegramClient customTelegramClient, final GameSessionRepository gameSessionRepository) {
+ this.customTelegramClient = customTelegramClient;
+ this.gameSessionRepository = gameSessionRepository;
+ }
+
+ @Order(RECORD_NOTIFICATION_ORDER)
+ @Async(ExecutorsConfiguration.NOTIFICATION_LISTENER_EXECUTOR_NAME)
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ void handleSessionRegistration(final GameSessionRegistrationEvent event) {
+ Duration submittedDuration = event.getDuration();
+ Long chatId = event.getChatId();
+
+ // Get all distinct durations ordered ascending (best first)
+ List distinctDurations = gameSessionRepository.findDistinctDurationsOrderedAsc(chatId, event.getGameType());
+
+ if (distinctDurations.isEmpty()) {
+ return; // No durations found (should not happen)
+ }
+
+ Duration bestDuration = distinctDurations.get(0);
+
+ // If the best duration in the DB is not the submitted one, this is not a record
+ if (submittedDuration.compareTo(bestDuration) != 0) {
+ return;
+ }
+
+ // Find the previous best (second best duration after the current record)
+ Optional previousBest = distinctDurations.size() > 1
+ ? Optional.of(distinctDurations.get(1)) // Get the second one (previous best)
+ : Optional.empty();
+
+ String gameName = event.getGameInfo().name();
+ String userMention = FormatUtils.formatUserMention(event.getUserInfo());
+ String formattedDuration = FormatUtils.formatDuration(submittedDuration);
+
+ if (previousBest.isPresent()) {
+ // There was a previous record — show it
+ customTelegramClient.sendMessage(
+ RECORD_BROKEN_MESSAGE_TEMPLATE.formatted(gameName, userMention, formattedDuration, FormatUtils.formatDuration(previousBest.get())),
+ event.getChatInfo().chatId()
+ );
+ } else {
+ // First ever submission for this game — it's the first record
+ customTelegramClient.sendMessage(
+ NEW_RECORD_MESSAGE_TEMPLATE.formatted(gameName, userMention, formattedDuration),
+ event.getChatInfo().chatId()
+ );
+ }
+ }
+}
diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRegistrationEvent.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRegistrationEvent.java
index c44f0b5..581dd16 100644
--- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRegistrationEvent.java
+++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRegistrationEvent.java
@@ -14,15 +14,17 @@ public class GameSessionRegistrationEvent extends ApplicationEvent {
private final ChatInfo chatInfo;
private final UserInfo userInfo;
private final GameInfo gameInfo;
+ private final GameType gameType;
private final Duration duration;
private final LocalDate gameDay;
private final Long chatId;
- public GameSessionRegistrationEvent(final Object source, final ChatInfo chatInfo, final UserInfo userInfo, final GameInfo gameInfo, final Duration duration, final LocalDate gameDay, final Long chatId) {
+ public GameSessionRegistrationEvent(final Object source, final ChatInfo chatInfo, final UserInfo userInfo, final GameInfo gameInfo, final GameType gameType, final Duration duration, final LocalDate gameDay, final Long chatId) {
super(source);
this.chatInfo = chatInfo;
this.userInfo = userInfo;
this.gameInfo = gameInfo;
+ this.gameType = gameType;
this.duration = duration;
this.gameDay = gameDay;
this.chatId = chatId;
diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java
index 320aeb8..ee0b41a 100644
--- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java
+++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionRepository.java
@@ -1,9 +1,13 @@
package dev.rubasace.linkedin.games.ldrbot.session;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
+import java.time.Duration;
import java.time.LocalDate;
+import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@@ -25,4 +29,9 @@ public interface GameSessionRepository extends JpaRepository
@Transactional
void deleteByUserIdAndGroupChatIdAndGameDay(Long UserId, Long chatId, LocalDate gameDay);
+
+ long countByGroupChatIdAndGame(Long chatId, GameType game);
+
+ @Query("SELECT DISTINCT gs.duration FROM GameSession gs WHERE gs.group.chatId = :chatId AND gs.game = :game ORDER BY gs.duration ASC")
+ List findDistinctDurationsOrderedAsc(@Param("chatId") Long chatId, @Param("game") GameType game);
}
diff --git a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionService.java b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionService.java
index 61be401..150ce97 100644
--- a/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionService.java
+++ b/src/main/java/dev/rubasace/linkedin/games/ldrbot/session/GameSessionService.java
@@ -73,7 +73,7 @@ public void recordGameSession(final ChatInfo chatInfo, final UserInfo userInfo,
private void saveSession(final ChatInfo chatInfo, final UserInfo userInfo, final LocalDate gameDay, final GameSession gameSession, final GameInfo gameInfo, final TelegramGroup telegramGroup) {
gameSessionRepository.saveAndFlush(gameSession);
- applicationEventPublisher.publishEvent(new GameSessionRegistrationEvent(this, chatInfo, userInfo, gameInfo, gameSession.getDuration(), gameDay,
+ applicationEventPublisher.publishEvent(new GameSessionRegistrationEvent(this, chatInfo, userInfo, gameInfo, gameSession.getGame(), gameSession.getDuration(), gameDay,
telegramGroup.getChatId()));
}
diff --git a/src/test/java/dev/rubasace/linkedin/games/ldrbot/ApplicationTests.java b/src/test/java/dev/rubasace/linkedin/games/ldrbot/ApplicationTests.java
index 561e204..f7c36a3 100644
--- a/src/test/java/dev/rubasace/linkedin/games/ldrbot/ApplicationTests.java
+++ b/src/test/java/dev/rubasace/linkedin/games/ldrbot/ApplicationTests.java
@@ -1,5 +1,6 @@
package dev.rubasace.linkedin.games.ldrbot;
+import dev.rubasace.linkedin.games.ldrbot.chat.CustomTelegramClient;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -11,6 +12,9 @@ class ApplicationTests {
@MockitoBean
private TelegramBotInitializer telegramBotInitializer;
+ @MockitoBean
+ private CustomTelegramClient customTelegramClient;
+
@Test
void contextLoads() {
}
diff --git a/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java
new file mode 100644
index 0000000..27d81e1
--- /dev/null
+++ b/src/test/java/dev/rubasace/linkedin/games/ldrbot/chat/RecordNotificationServiceTest.java
@@ -0,0 +1,115 @@
+package dev.rubasace.linkedin.games.ldrbot.chat;
+
+import dev.rubasace.linkedin.games.ldrbot.group.ChatInfo;
+import dev.rubasace.linkedin.games.ldrbot.session.*;
+import dev.rubasace.linkedin.games.ldrbot.user.UserInfo;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class RecordNotificationServiceTest {
+
+ @Mock
+ private CustomTelegramClient customTelegramClient;
+
+ @Mock
+ private GameSessionRepository gameSessionRepository;
+
+ private RecordNotificationService service;
+
+ private static final Long CHAT_ID = 123L;
+ private static final GameType GAME = GameType.QUEENS;
+ private static final ChatInfo CHAT_INFO = new ChatInfo(CHAT_ID, "Test Group", true);
+ private static final UserInfo USER_INFO = new UserInfo(1L, "testuser", "Test", "User");
+ private static final GameInfo GAME_INFO = new GameInfo("Queens", "👑");
+
+ @BeforeEach
+ void setUp() {
+ service = new RecordNotificationService(customTelegramClient, gameSessionRepository);
+ }
+
+ private GameSessionRegistrationEvent createEvent(Duration duration) {
+ return new GameSessionRegistrationEvent(this, CHAT_INFO, USER_INFO, GAME_INFO, GAME, duration, LocalDate.now(), CHAT_ID);
+ }
+
+ @Test
+ void shouldNotNotifyWhenSubmittedDurationIsNotTheBest() {
+ Duration submitted = Duration.ofSeconds(120);
+ Duration best = Duration.ofSeconds(60);
+ when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(best, submitted));
+
+ service.handleSessionRegistration(createEvent(submitted));
+
+ verifyNoInteractions(customTelegramClient);
+ }
+
+ @Test
+ void shouldNotNotifyWhenNoBestDurationFound() {
+ when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of());
+
+ service.handleSessionRegistration(createEvent(Duration.ofSeconds(60)));
+
+ verifyNoInteractions(customTelegramClient);
+ }
+
+ @Test
+ void shouldNotifyFirstRecordEver() {
+ Duration submitted = Duration.ofSeconds(90);
+ when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(submitted));
+
+ service.handleSessionRegistration(createEvent(submitted));
+
+ ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class);
+ verify(customTelegramClient).sendMessage(messageCaptor.capture(), eq(CHAT_ID));
+ String message = messageCaptor.getValue();
+ assertTrue(message.contains("New Queens record!"));
+ assertTrue(message.contains("@testuser"));
+ assertTrue(message.contains("01:30"));
+ assertFalse(message.contains("previous record"));
+ }
+
+ @Test
+ void shouldNotifyRecordBrokenWithPreviousBest() {
+ Duration submitted = Duration.ofSeconds(45);
+ Duration previous = Duration.ofSeconds(90);
+ when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(submitted, previous));
+
+ service.handleSessionRegistration(createEvent(submitted));
+
+ ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class);
+ verify(customTelegramClient).sendMessage(messageCaptor.capture(), eq(CHAT_ID));
+ String message = messageCaptor.getValue();
+ assertTrue(message.contains("New Queens record!"));
+ assertTrue(message.contains("@testuser"));
+ assertTrue(message.contains("00:45"));
+ assertTrue(message.contains("previous record: 01:30"));
+ }
+
+ @Test
+ void shouldFormatUserWithoutUsernameAsMention() {
+ UserInfo noUsername = new UserInfo(42L, null, "John", "Doe");
+ Duration submitted = Duration.ofSeconds(30);
+ GameSessionRegistrationEvent event = new GameSessionRegistrationEvent(this, CHAT_INFO, noUsername, GAME_INFO, GAME, submitted, LocalDate.now(), CHAT_ID);
+ when(gameSessionRepository.findDistinctDurationsOrderedAsc(CHAT_ID, GAME)).thenReturn(List.of(submitted));
+
+ service.handleSessionRegistration(event);
+
+ ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class);
+ verify(customTelegramClient).sendMessage(messageCaptor.capture(), eq(CHAT_ID));
+ String message = messageCaptor.getValue();
+ assertTrue(message.contains("tg://user?id=42"));
+ assertTrue(message.contains("John Doe"));
+ }
+}