fix(analytics): recalculate delete side effects asynchronously
Move achievement and summary rebuilds out of the activity delete request path so the UI is not blocked by long-running recalculations. Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
parent
d86b8b9d23
commit
2ae0eeb06b
5 changed files with 91 additions and 13 deletions
|
|
@ -0,0 +1,30 @@
|
||||||
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.event.TransactionPhase;
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculates derived analytics after an activity has been deleted.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class ActivityDeleteRecalculationService {
|
||||||
|
|
||||||
|
private final AchievementService achievementService;
|
||||||
|
private final ActivitySummaryService activitySummaryService;
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||||
|
public void handleActivityDeleted(ActivityDeletedEvent event) {
|
||||||
|
achievementService.rebuildAchievementsForUser(event.userId());
|
||||||
|
activitySummaryService.updateWeeklySummary(event.userId(), event.activityDate());
|
||||||
|
activitySummaryService.updateMonthlySummary(event.userId(), event.activityDate());
|
||||||
|
activitySummaryService.updateYearlySummary(event.userId(), event.activityDate());
|
||||||
|
log.info("Recalculated achievements and summaries after deleting activity for user {}", event.userId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record ActivityDeletedEvent(UUID userId, LocalDate activityDate) {
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import net.javahippie.fitpub.util.FitFileValidator;
|
||||||
import net.javahippie.fitpub.util.FitParser;
|
import net.javahippie.fitpub.util.FitParser;
|
||||||
import net.javahippie.fitpub.util.ParsedActivityData;
|
import net.javahippie.fitpub.util.ParsedActivityData;
|
||||||
import net.javahippie.fitpub.util.TrackSimplifier;
|
import net.javahippie.fitpub.util.TrackSimplifier;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
@ -52,6 +53,7 @@ public class FitFileService {
|
||||||
private final ActivitySummaryService activitySummaryService;
|
private final ActivitySummaryService activitySummaryService;
|
||||||
private final WeatherService weatherService;
|
private final WeatherService weatherService;
|
||||||
private final HeatmapGridService heatmapGridService;
|
private final HeatmapGridService heatmapGridService;
|
||||||
|
private final ApplicationEventPublisher applicationEventPublisher;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes an uploaded FIT file and creates an activity.
|
* Processes an uploaded FIT file and creates an activity.
|
||||||
|
|
@ -319,12 +321,10 @@ public class FitFileService {
|
||||||
return activityRepository.findByIdAndUserId(activityId, userId)
|
return activityRepository.findByIdAndUserId(activityId, userId)
|
||||||
.map(activity -> {
|
.map(activity -> {
|
||||||
activityRepository.delete(activity);
|
activityRepository.delete(activity);
|
||||||
achievementService.rebuildAchievementsForUser(userId);
|
|
||||||
if (activity.getStartedAt() != null) {
|
if (activity.getStartedAt() != null) {
|
||||||
java.time.LocalDate activityDate = activity.getStartedAt().toLocalDate();
|
applicationEventPublisher.publishEvent(
|
||||||
activitySummaryService.updateWeeklySummary(userId, activityDate);
|
new ActivityDeletedEvent(userId, activity.getStartedAt().toLocalDate())
|
||||||
activitySummaryService.updateMonthlySummary(userId, activityDate);
|
);
|
||||||
activitySummaryService.updateYearlySummary(userId, activityDate);
|
|
||||||
}
|
}
|
||||||
log.info("Deleted activity {} for user {}", activityId, userId);
|
log.info("Deleted activity {} for user {}", activityId, userId);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ActivityDeleteRecalculationServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AchievementService achievementService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ActivitySummaryService activitySummaryService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ActivityDeleteRecalculationService activityDeleteRecalculationService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should rebuild achievements and summaries after activity deletion")
|
||||||
|
void shouldRebuildAchievementsAndSummariesAfterActivityDeletion() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
LocalDate activityDate = LocalDate.of(2025, 12, 3);
|
||||||
|
|
||||||
|
activityDeleteRecalculationService.handleActivityDeleted(new ActivityDeletedEvent(userId, activityDate));
|
||||||
|
|
||||||
|
verify(achievementService).rebuildAchievementsForUser(userId);
|
||||||
|
verify(activitySummaryService).updateWeeklySummary(userId, activityDate);
|
||||||
|
verify(activitySummaryService).updateMonthlySummary(userId, activityDate);
|
||||||
|
verify(activitySummaryService).updateYearlySummary(userId, activityDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import net.javahippie.fitpub.util.FitFileValidator;
|
||||||
import net.javahippie.fitpub.util.FitParser;
|
import net.javahippie.fitpub.util.FitParser;
|
||||||
import net.javahippie.fitpub.util.ParsedActivityData;
|
import net.javahippie.fitpub.util.ParsedActivityData;
|
||||||
import net.javahippie.fitpub.util.TrackSimplifier;
|
import net.javahippie.fitpub.util.TrackSimplifier;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
|
@ -74,6 +75,9 @@ class FitFileServiceTest {
|
||||||
@Mock
|
@Mock
|
||||||
private HeatmapGridService heatmapGridService;
|
private HeatmapGridService heatmapGridService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApplicationEventPublisher applicationEventPublisher;
|
||||||
|
|
||||||
@Spy
|
@Spy
|
||||||
private ObjectMapper objectMapper;
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
|
@ -240,10 +244,10 @@ class FitFileServiceTest {
|
||||||
// Assert
|
// Assert
|
||||||
assertTrue(result);
|
assertTrue(result);
|
||||||
verify(activityRepository).delete(activity);
|
verify(activityRepository).delete(activity);
|
||||||
verify(achievementService).rebuildAchievementsForUser(testUserId);
|
ArgumentCaptor<ActivityDeletedEvent> eventCaptor = ArgumentCaptor.forClass(ActivityDeletedEvent.class);
|
||||||
verify(activitySummaryService).updateWeeklySummary(testUserId, startedAt.toLocalDate());
|
verify(applicationEventPublisher).publishEvent(eventCaptor.capture());
|
||||||
verify(activitySummaryService).updateMonthlySummary(testUserId, startedAt.toLocalDate());
|
assertEquals(testUserId, eventCaptor.getValue().userId());
|
||||||
verify(activitySummaryService).updateYearlySummary(testUserId, startedAt.toLocalDate());
|
assertEquals(startedAt.toLocalDate(), eventCaptor.getValue().activityDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -260,10 +264,7 @@ class FitFileServiceTest {
|
||||||
// Assert
|
// Assert
|
||||||
assertFalse(result);
|
assertFalse(result);
|
||||||
verify(activityRepository, never()).delete(any());
|
verify(activityRepository, never()).delete(any());
|
||||||
verify(achievementService, never()).rebuildAchievementsForUser(any());
|
verify(applicationEventPublisher, never()).publishEvent(any());
|
||||||
verify(activitySummaryService, never()).updateWeeklySummary(any(), any());
|
|
||||||
verify(activitySummaryService, never()).updateMonthlySummary(any(), any());
|
|
||||||
verify(activitySummaryService, never()).updateYearlySummary(any(), any());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue