diff --git a/src/main/java/net/javahippie/fitpub/model/entity/Activity.java b/src/main/java/net/javahippie/fitpub/model/entity/Activity.java index 6b2fb52..045bd7a 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/Activity.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/Activity.java @@ -117,13 +117,6 @@ public class Activity { @Column(name = "source_file_format", nullable = false, length = 10) private String sourceFileFormat; - /** - * Optional internal reference to the originating Komoot activity. - * Used for import matching and deduplication only. - */ - @Column(name = "komoot_activity_id") - private Long komootActivityId; - /** * Indicates if this is an indoor activity (e.g., virtual rides, indoor trainer sessions). * Indoor activities are displayed in timeline but excluded from heatmap generation. diff --git a/src/main/java/net/javahippie/fitpub/model/entity/KomootImport.java b/src/main/java/net/javahippie/fitpub/model/entity/KomootImport.java new file mode 100644 index 0000000..d922504 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/entity/KomootImport.java @@ -0,0 +1,46 @@ +package net.javahippie.fitpub.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Internal link between a FitPub activity and its originating Komoot activity. + */ +@Entity +@Table(name = "komoot_imports", + uniqueConstraints = { + @UniqueConstraint(name = "uk_komoot_imports_activity_id", columnNames = "activity_id"), + @UniqueConstraint(name = "uk_komoot_imports_user_komoot_activity_id", columnNames = {"user_id", "komoot_activity_id"}) + }, + indexes = { + @Index(name = "idx_komoot_imports_user_id", columnList = "user_id"), + @Index(name = "idx_komoot_imports_komoot_activity_id", columnList = "komoot_activity_id") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class KomootImport { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "activity_id", nullable = false) + private UUID activityId; + + @Column(name = "komoot_activity_id", nullable = false) + private Long komootActivityId; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java index 7a8c03d..55483a2 100644 --- a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java @@ -20,37 +20,9 @@ import java.util.UUID; @Repository public interface ActivityRepository extends JpaRepository { - interface KomootImportLinkProjection { - UUID getId(); - Long getKomootActivityId(); - } - @Query("SELECT a.id FROM Activity a") List findAllIds(); - /** - * Returns all imported Komoot activity IDs for the given local user. - */ - @Query("SELECT a.komootActivityId FROM Activity a " + - "WHERE a.userId = :userId AND a.komootActivityId IS NOT NULL") - List findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId); - - /** - * Finds a previously imported Komoot activity for the given user. - */ - Optional findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId); - - /** - * Finds imported Komoot activities for the given user and Komoot IDs. - */ - @Query("SELECT a.id AS id, a.komootActivityId AS komootActivityId " + - "FROM Activity a " + - "WHERE a.userId = :userId AND a.komootActivityId IN :komootActivityIds") - List findKomootImportLinksByUserIdAndKomootActivityIdIn( - @Param("userId") UUID userId, - @Param("komootActivityIds") List komootActivityIds - ); - /** * Find all activities for a specific user. * diff --git a/src/main/java/net/javahippie/fitpub/repository/KomootImportRepository.java b/src/main/java/net/javahippie/fitpub/repository/KomootImportRepository.java new file mode 100644 index 0000000..aaf4ea2 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/repository/KomootImportRepository.java @@ -0,0 +1,33 @@ +package net.javahippie.fitpub.repository; + +import net.javahippie.fitpub.model.entity.KomootImport; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface KomootImportRepository extends JpaRepository { + + interface KomootImportLinkProjection { + UUID getActivityId(); + Long getKomootActivityId(); + } + + @Query("SELECT k.komootActivityId FROM KomootImport k WHERE k.userId = :userId") + List findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId); + + Optional findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId); + + @Query("SELECT k.activityId AS activityId, k.komootActivityId AS komootActivityId " + + "FROM KomootImport k " + + "WHERE k.userId = :userId AND k.komootActivityId IN :komootActivityIds") + List findKomootImportLinksByUserIdAndKomootActivityIdIn( + @Param("userId") UUID userId, + @Param("komootActivityIds") List komootActivityIds + ); +} diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index 645838f..f87c607 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -9,7 +9,9 @@ import net.javahippie.fitpub.model.dto.KomootActivitySummaryDTO; import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse; import net.javahippie.fitpub.model.dto.KomootImportRequest; import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.model.entity.KomootImport; import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.KomootImportRepository; import net.javahippie.fitpub.util.ByteArrayMultipartFile; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; @@ -57,6 +59,7 @@ public class KomootImportService { private static final DateTimeFormatter KOMOOT_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX"); private final RestTemplate restTemplate; private final ActivityRepository activityRepository; + private final KomootImportRepository komootImportRepository; private final ActivityFileService activityFileService; private final ActivityPostProcessingService activityPostProcessingService; @@ -75,14 +78,14 @@ public class KomootImportService { public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request, UUID fitPubUserId) { List activities = new ArrayList<>(); Set importedKomootActivityIds = new HashSet<>( - activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); + komootImportRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); Map fitPubActivityIdsByKomootId = new HashMap<>(); if (!importedKomootActivityIds.isEmpty()) { - activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn( + komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn( fitPubUserId, new ArrayList<>(importedKomootActivityIds) ) - .forEach(link -> fitPubActivityIdsByKomootId.put(link.getKomootActivityId(), link.getId())); + .forEach(link -> fitPubActivityIdsByKomootId.put(link.getKomootActivityId(), link.getActivityId())); } URI nextUri = buildInitialUri(request); @@ -122,10 +125,10 @@ public class KomootImportService { } public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) { - Activity existingActivity = activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.getActivityId()).orElse(null); - if (existingActivity != null) { + KomootImport existingImport = komootImportRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.getActivityId()).orElse(null); + if (existingImport != null) { return new KomootImportExecutionResponse( - existingActivity.getId(), + existingImport.getActivityId(), request.getActivityId(), "SKIPPED_ALREADY_IMPORTED", "Komoot activity " + request.getActivityId() + " was already imported." @@ -156,13 +159,17 @@ public class KomootImportService { mappedVisibility ); - importedActivity.setKomootActivityId(request.getActivityId()); importedActivity.setTitle(mappedTitle); importedActivity.setDescription(mappedDescription); importedActivity.setVisibility(mappedVisibility); importedActivity.setActivityType(mappedActivityType); importedActivity = activityRepository.save(importedActivity); + komootImportRepository.save(KomootImport.builder() + .userId(fitPubUserId) + .activityId(importedActivity.getId()) + .komootActivityId(request.getActivityId()) + .build()); activityPostProcessingService.processActivityAsync(importedActivity.getId(), fitPubUserId); log.info( diff --git a/src/main/resources/db/migration/V32__add_komoot_activity_id_to_activities.sql b/src/main/resources/db/migration/V32__add_komoot_activity_id_to_activities.sql deleted file mode 100644 index 24ef05f..0000000 --- a/src/main/resources/db/migration/V32__add_komoot_activity_id_to_activities.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Add optional internal reference to the originating Komoot activity. --- --- This field is only used for import matching and deduplication. It is not --- intended for public display or API exposure. - -ALTER TABLE activities - ADD COLUMN komoot_activity_id BIGINT; - --- A Komoot activity may only be imported once per local user. -CREATE UNIQUE INDEX idx_activities_user_komoot_activity_id - ON activities(user_id, komoot_activity_id) - WHERE komoot_activity_id IS NOT NULL; - -COMMENT ON COLUMN activities.komoot_activity_id IS - 'Optional internal Komoot activity ID used for import matching and deduplication'; diff --git a/src/main/resources/db/migration/V32__create_komoot_imports_table.sql b/src/main/resources/db/migration/V32__create_komoot_imports_table.sql new file mode 100644 index 0000000..e6b7524 --- /dev/null +++ b/src/main/resources/db/migration/V32__create_komoot_imports_table.sql @@ -0,0 +1,23 @@ +-- Track imported Komoot activities separately from the core activities table. +-- +-- This keeps the import-specific state isolated and allows all import-related +-- columns to be strictly non-nullable. + +CREATE TABLE komoot_imports ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + komoot_activity_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_komoot_imports_activity_id UNIQUE (activity_id), + CONSTRAINT uk_komoot_imports_user_komoot_activity_id UNIQUE (user_id, komoot_activity_id) +); + +CREATE INDEX idx_komoot_imports_user_id + ON komoot_imports(user_id); + +CREATE INDEX idx_komoot_imports_komoot_activity_id + ON komoot_imports(komoot_activity_id); + +COMMENT ON TABLE komoot_imports IS + 'Internal mapping between FitPub activities and their originating Komoot activities'; diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index 0a0803d..5cf85a5 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -5,7 +5,9 @@ import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse; import net.javahippie.fitpub.model.dto.KomootImportRequest; import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.model.entity.KomootImport; import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.KomootImportRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -41,10 +43,10 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat class KomootImportServiceTest { - private static ActivityRepository.KomootImportLinkProjection importLink(UUID activityId, Long komootActivityId) { - return new ActivityRepository.KomootImportLinkProjection() { + private static KomootImportRepository.KomootImportLinkProjection importLink(UUID activityId, Long komootActivityId) { + return new KomootImportRepository.KomootImportLinkProjection() { @Override - public UUID getId() { + public UUID getActivityId() { return activityId; } @@ -58,6 +60,7 @@ class KomootImportServiceTest { private MockRestServiceServer server; private KomootImportService service; private ActivityRepository activityRepository; + private KomootImportRepository komootImportRepository; private ActivityFileService activityFileService; private ActivityPostProcessingService activityPostProcessingService; private TimeZone originalTimeZone; @@ -69,9 +72,10 @@ class KomootImportServiceTest { RestTemplate restTemplate = new RestTemplate(); server = MockRestServiceServer.bindTo(restTemplate).build(); activityRepository = mock(ActivityRepository.class); + komootImportRepository = mock(KomootImportRepository.class); activityFileService = mock(ActivityFileService.class); activityPostProcessingService = mock(ActivityPostProcessingService.class); - service = new KomootImportService(restTemplate, activityRepository, activityFileService, activityPostProcessingService); + service = new KomootImportService(restTemplate, activityRepository, komootImportRepository, activityFileService, activityPostProcessingService); ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com"); ReflectionTestUtils.setField(service, "paginatedRequestDelayMillis", 0L); ReflectionTestUtils.setField(service, "detailToGpxDelayMillis", 0L); @@ -92,8 +96,8 @@ class KomootImportServiceTest { doNothing().when(throttledService).pauseBeforeNextPageRequest(); UUID existingActivityId = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); - when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L)); - when(activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1002L))) + when(komootImportRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L)); + when(komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1002L))) .thenReturn(List.of(importLink(existingActivityId, 1002L))); server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0")) @@ -176,8 +180,8 @@ class KomootImportServiceTest { UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); UUID existingActivityId = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"); - when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1003L)); - when(activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1003L))) + when(komootImportRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1003L)); + when(komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1003L))) .thenReturn(List.of(importLink(existingActivityId, 1003L))); server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&start_date=2026-04-25T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z")) @@ -252,7 +256,7 @@ class KomootImportServiceTest { doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest(); doNothing().when(throttledService).pauseAfterActivityImport(); - when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty()); + when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty()); server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035?hl=en")) .andExpect(method(HttpMethod.GET)) @@ -290,6 +294,7 @@ class KomootImportServiceTest { when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0)); KomootImportExecutionResponse response = throttledService.importActivity( new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957035L), @@ -299,11 +304,11 @@ class KomootImportServiceTest { assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId); assertThat(response.getImportedKomootActivityId()).isEqualTo(2880957035L); assertThat(response.getStatus()).isEqualTo("IMPORTED"); - assertThat(importedActivity.getKomootActivityId()).isEqualTo(2880957035L); assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride"); assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot"); assertThat(importedActivity.getVisibility()).isEqualTo(Activity.Visibility.PUBLIC); assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.RIDE); + verify(komootImportRepository).save(any(KomootImport.class)); verify(throttledService).pauseBetweenDetailAndGpxRequest(); verify(throttledService).pauseAfterActivityImport(); @@ -317,8 +322,8 @@ class KomootImportServiceTest { UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); UUID existingActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); - when(activityRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn( - Optional.of(Activity.builder().id(existingActivityId).userId(userId).komootActivityId(3002L).build()) + when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn( + Optional.of(KomootImport.builder().activityId(existingActivityId).userId(userId).komootActivityId(3002L).build()) ); KomootImportExecutionResponse response = service.importActivity( @@ -342,7 +347,7 @@ class KomootImportServiceTest { doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest(); doNothing().when(throttledService).pauseAfterActivityImport(); - when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty()); + when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty()); server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036?hl=en")) .andExpect(method(HttpMethod.GET)) @@ -380,6 +385,7 @@ class KomootImportServiceTest { when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0)); KomootImportExecutionResponse response = throttledService.importActivity( new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957036L), @@ -389,6 +395,7 @@ class KomootImportServiceTest { assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId); assertThat(response.getStatus()).isEqualTo("IMPORTED"); assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER); + verify(komootImportRepository).save(any(KomootImport.class)); verify(throttledService).pauseBetweenDetailAndGpxRequest(); verify(throttledService).pauseAfterActivityImport();