fix(analytics): rebuild achievements from stable history snapshot

Prevent delete-triggered achievement rebuilds from failing when activity history changes again while an async recalculation is already running.

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
Marcus Fihlon 2026-04-29 12:18:55 +02:00
parent 714007aabe
commit 251beaae0f
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
2 changed files with 47 additions and 5 deletions

View file

@ -41,10 +41,8 @@ public class AchievementService {
*/
@Transactional
public List<Achievement> checkAndAwardAchievements(Activity activity) {
List<Achievement> newAchievements = new ArrayList<>();
if (activity.getUserId() == null || activity.getStartedAt() == null || activity.getEndedAt() == null) {
return newAchievements;
return List.of();
}
UUID userId = activity.getUserId();
@ -59,6 +57,15 @@ public class AchievementService {
existing.add(a.getAchievementType());
}
return checkAndAwardAchievements(activity, progress, existing);
}
private List<Achievement> checkAndAwardAchievements(Activity activity,
ActivityProgress progress,
Set<Achievement.AchievementType> existing) {
List<Achievement> newAchievements = new ArrayList<>();
UUID userId = activity.getUserId();
// Check first activity achievements
newAchievements.addAll(checkFirstActivityAchievements(userId, activity, progress, existing));
@ -83,6 +90,10 @@ public class AchievementService {
// Check speed achievements
newAchievements.addAll(checkSpeedAchievements(userId, activity, progress, existing));
for (Achievement achievement : newAchievements) {
existing.add(achievement.getAchievementType());
}
return newAchievements;
}
@ -103,9 +114,15 @@ public class AchievementService {
achievementRepository.deleteByUserId(userId);
Set<Achievement.AchievementType> existing = EnumSet.noneOf(Achievement.AchievementType.class);
List<Achievement> rebuiltAchievements = new ArrayList<>();
for (Activity activity : activityHistory) {
rebuiltAchievements.addAll(checkAndAwardAchievements(activity));
for (int i = 0; i < activityHistory.size(); i++) {
Activity activity = activityHistory.get(i);
rebuiltAchievements.addAll(checkAndAwardAchievements(
activity,
ActivityProgress.fromHistory(activityHistory, i),
existing
));
}
return rebuiltAchievements;
@ -560,6 +577,10 @@ public class AchievementService {
throw new IllegalStateException("Current activity missing from chronological history: " + currentActivity.getId());
}
return fromHistory(activityHistory, currentIndex);
}
private static ActivityProgress fromHistory(List<Activity> activityHistory, int currentIndex) {
return new ActivityProgress(
List.copyOf(activityHistory.subList(0, currentIndex)),
List.copyOf(activityHistory.subList(0, currentIndex + 1))

View file

@ -414,6 +414,27 @@ class AchievementServiceTest {
inOrder.verify(achievementRepository, atLeastOnce()).save(any(Achievement.class));
}
@Test
@DisplayName("Should rebuild achievements from a stable history snapshot")
void testRebuildAchievementsForUser_UsesStableHistorySnapshot() {
Activity previous = createActivity(Activity.ActivityType.RUN, 9000L, BigDecimal.ZERO);
previous.setStartedAt(LocalDateTime.of(2025, 11, 30, 10, 0));
previous.setEndedAt(LocalDateTime.of(2025, 11, 30, 11, 0));
Activity activity = createActivity(Activity.ActivityType.RUN, 2000L, BigDecimal.ZERO);
activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 7, 15));
activity.setEndedAt(LocalDateTime.of(2025, 12, 1, 8, 5));
when(activityRepository.findByUserIdOrderByStartedAtAsc(userId))
.thenReturn(List.of(previous, activity))
.thenReturn(List.of(previous));
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
List<Achievement> rebuilt = achievementService.rebuildAchievementsForUser(userId);
assertTrue(rebuilt.stream().anyMatch(a -> a.getAchievementType() == Achievement.AchievementType.DISTANCE_10K));
verify(activityRepository, times(1)).findByUserIdOrderByStartedAtAsc(userId);
}
@Test
@DisplayName("Should get user achievements")
void testGetUserAchievements() {