From ea47cccdb158f3b598168132a523cc0917537b5e Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 11:01:21 +0200 Subject: [PATCH 1/2] refactor(komoot): align DTOs with Lombok class pattern Signed-off-by: Marcus Fihlon --- .../controller/KomootImportController.java | 4 +- .../model/dto/KomootActivitiesResponse.java | 19 +++++-- .../dto/KomootActivityImportRequest.java | 32 ++++++----- .../model/dto/KomootActivitySummaryDTO.java | 39 ++++++++------ .../dto/KomootImportExecutionResponse.java | 21 +++++--- .../fitpub/model/dto/KomootImportRequest.java | 53 ++++++++++++++----- .../fitpub/service/KomootImportService.java | 36 ++++++------- .../service/KomootImportServiceTest.java | 44 +++++++-------- 8 files changed, 155 insertions(+), 93 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java index 99c1f9e..146ebe3 100644 --- a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java @@ -43,7 +43,7 @@ public class KomootImportController { .getId(); log.info("User {} requested Komoot activity preview for Komoot ID {}", - authentication.getName(), request.userId()); + authentication.getName(), request.getUserId()); KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request, fitPubUserId); return ResponseEntity.ok(response); } @@ -58,7 +58,7 @@ public class KomootImportController { .getId(); log.info("User {} requested Komoot import for activity {}", - authentication.getName(), request.activityId()); + authentication.getName(), request.getActivityId()); KomootImportExecutionResponse response = komootImportService.importActivity( request, diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java index c9223d7..296acb3 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java @@ -1,13 +1,22 @@ package net.javahippie.fitpub.model.dto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + import java.util.List; /** * Response payload for the Komoot import preview. */ -public record KomootActivitiesResponse( - String userId, - int totalCount, - List activities -) { +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class KomootActivitiesResponse { + + private String userId; + private int totalCount; + private List activities; } diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivityImportRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivityImportRequest.java index 1c67621..6d9b7eb 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivityImportRequest.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivityImportRequest.java @@ -4,25 +4,33 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; /** * Request payload for importing one specific Komoot activity. * *

The password is only used for the current request and is never persisted.

*/ -public record KomootActivityImportRequest( - @NotBlank - @Email - String email, +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class KomootActivityImportRequest { - @NotBlank - String password, + @NotBlank + @Email + private String email; - @NotBlank - @Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only") - String userId, + @NotBlank + private String password; - @NotNull - Long activityId -) { + @NotBlank + @Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only") + private String userId; + + @NotNull + private Long activityId; } diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java index a75ae5b..3bdf613 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java @@ -1,24 +1,33 @@ package net.javahippie.fitpub.model.dto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + import java.time.OffsetDateTime; import java.util.UUID; /** * Reduced activity representation returned by the Komoot import preview. */ -public record KomootActivitySummaryDTO( - long id, - String name, - String sport, - String mappedActivityType, - String status, - String type, - OffsetDateTime date, - Double distanceMeters, - Integer durationSeconds, - Integer timeInMotionSeconds, - Double elevationUp, - boolean imported, - UUID fitPubActivityId -) { +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class KomootActivitySummaryDTO { + + private long id; + private String name; + private String sport; + private String mappedActivityType; + private String status; + private String type; + private OffsetDateTime date; + private Double distanceMeters; + private Integer durationSeconds; + private Integer timeInMotionSeconds; + private Double elevationUp; + private boolean imported; + private UUID fitPubActivityId; } diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java index dc80fe3..abb31e8 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java @@ -1,14 +1,23 @@ package net.javahippie.fitpub.model.dto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + import java.util.UUID; /** * Response for importing exactly one Komoot activity into FitPub. */ -public record KomootImportExecutionResponse( - UUID importedActivityId, - Long importedKomootActivityId, - String status, - String message -) { +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class KomootImportExecutionResponse { + + private UUID importedActivityId; + private Long importedKomootActivityId; + private String status; + private String message; } diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java index 345f1b9..5c58345 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java @@ -1,8 +1,12 @@ package net.javahippie.fitpub.model.dto; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; import java.time.LocalDate; @@ -11,23 +15,46 @@ import java.time.LocalDate; * *

The password is only used for the current request and is never persisted.

*/ -public record KomootImportRequest( - @NotBlank - @Email - String email, +@Data +@Builder +@NoArgsConstructor +public class KomootImportRequest { - @NotBlank - String password, + @NotBlank + @Email + private String email; - @NotBlank - @Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only") - String userId, + @NotBlank + private String password; - LocalDate startDate, + @NotBlank + @Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only") + private String userId; - LocalDate endDate -) { - public KomootImportRequest { + private LocalDate startDate; + + private LocalDate endDate; + + public KomootImportRequest(String email, String password, String userId, LocalDate startDate, LocalDate endDate) { + this.email = email; + this.password = password; + this.userId = userId; + this.startDate = startDate; + this.endDate = endDate; + validateDateRange(); + } + + @AssertTrue(message = "Start date and end date must either both be set or both be empty, and start date must be before or equal to end date.") + public boolean isDateRangeConsistent() { + try { + validateDateRange(); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + private void validateDateRange() { boolean onlyOneDateProvided = (startDate == null) != (endDate == null); if (onlyOneDateProvided) { throw new IllegalArgumentException("Start date and end date must either both be set or both be empty."); diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index 7f92073..645838f 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -86,7 +86,7 @@ public class KomootImportService { } URI nextUri = buildInitialUri(request); - HttpEntity httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); + HttpEntity httpEntity = new HttpEntity<>(buildHeaders(request.getEmail(), request.getPassword())); try { while (nextUri != null) { @@ -113,8 +113,8 @@ public class KomootImportService { throw new IllegalStateException("Failed to parse Komoot activity list.", e); } - log.info("Fetched {} completed Komoot activities for user ID {}", activities.size(), request.userId()); - return new KomootActivitiesResponse(request.userId(), activities.size(), activities); + log.info("Fetched {} completed Komoot activities for user ID {}", activities.size(), request.getUserId()); + return new KomootActivitiesResponse(request.getUserId(), activities.size(), activities); } void pauseBeforeNextPageRequest() { @@ -122,29 +122,29 @@ public class KomootImportService { } public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) { - Activity existingActivity = activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).orElse(null); + Activity existingActivity = activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.getActivityId()).orElse(null); if (existingActivity != null) { return new KomootImportExecutionResponse( existingActivity.getId(), - request.activityId(), + request.getActivityId(), "SKIPPED_ALREADY_IMPORTED", - "Komoot activity " + request.activityId() + " was already imported." + "Komoot activity " + request.getActivityId() + " was already imported." ); } - JsonNode details = fetchActivityDetails(request.email(), request.password(), request.activityId()); + JsonNode details = fetchActivityDetails(request.getEmail(), request.getPassword(), request.getActivityId()); pauseBetweenDetailAndGpxRequest(); - byte[] gpxData = fetchActivityGpx(request.email(), request.password(), request.activityId()); + byte[] gpxData = fetchActivityGpx(request.getEmail(), request.getPassword(), request.getActivityId()); ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile( "file", - "komoot-" + request.activityId() + ".gpx", + "komoot-" + request.getActivityId() + ".gpx", "application/gpx+xml", gpxData ); Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status")); - String mappedTitle = firstNonBlank(nullableText(details, "name"), null, "Komoot Activity " + request.activityId()); + String mappedTitle = firstNonBlank(nullableText(details, "name"), null, "Komoot Activity " + request.getActivityId()); String mappedDescription = nullableText(details, "description"); Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport")); @@ -156,7 +156,7 @@ public class KomootImportService { mappedVisibility ); - importedActivity.setKomootActivityId(request.activityId()); + importedActivity.setKomootActivityId(request.getActivityId()); importedActivity.setTitle(mappedTitle); importedActivity.setDescription(mappedDescription); importedActivity.setVisibility(mappedVisibility); @@ -167,7 +167,7 @@ public class KomootImportService { log.info( "Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}", - request.activityId(), + request.getActivityId(), importedActivity.getId(), importedActivity.getVisibility(), importedActivity.getActivityType() @@ -177,9 +177,9 @@ public class KomootImportService { return new KomootImportExecutionResponse( importedActivity.getId(), - request.activityId(), + request.getActivityId(), "IMPORTED", - "Imported Komoot activity " + request.activityId() + " into FitPub activity " + importedActivity.getId() + "Imported Komoot activity " + request.getActivityId() + " into FitPub activity " + importedActivity.getId() ); } @@ -193,15 +193,15 @@ public class KomootImportService { private URI buildInitialUri(KomootImportRequest request) { String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; - UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + request.userId() + "/tours/") + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + request.getUserId() + "/tours/") .queryParam("type", "tour_recorded") .queryParam("sort_field", "date") .queryParam("sort_direction", "desc") .queryParam("limit", PAGE_SIZE); - if (request.startDate() != null && request.endDate() != null) { - builder.queryParam("start_date", formatKomootStartDate(request.startDate())) - .queryParam("end_date", formatKomootEndDate(request.endDate())); + if (request.getStartDate() != null && request.getEndDate() != null) { + builder.queryParam("start_date", formatKomootStartDate(request.getStartDate())) + .queryParam("end_date", formatKomootEndDate(request.getEndDate())); } else { builder.queryParam("status", "private") .queryParam("name", "") diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index 37d43d7..0a0803d 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -154,15 +154,15 @@ class KomootImportServiceTest { new KomootImportRequest("user@example.com", "secret", "123456", null, null), userId); - assertThat(response.totalCount()).isEqualTo(2); - assertThat(response.activities()).hasSize(2); - assertThat(response.activities().get(0).id()).isEqualTo(1001L); - assertThat(response.activities().get(0).imported()).isFalse(); - assertThat(response.activities().get(0).fitPubActivityId()).isNull(); - assertThat(response.activities().get(0).timeInMotionSeconds()).isEqualTo(7800); - assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk"); - assertThat(response.activities().get(1).imported()).isTrue(); - assertThat(response.activities().get(1).fitPubActivityId()).isEqualTo(existingActivityId); + assertThat(response.getTotalCount()).isEqualTo(2); + assertThat(response.getActivities()).hasSize(2); + assertThat(response.getActivities().get(0).getId()).isEqualTo(1001L); + assertThat(response.getActivities().get(0).isImported()).isFalse(); + assertThat(response.getActivities().get(0).getFitPubActivityId()).isNull(); + assertThat(response.getActivities().get(0).getTimeInMotionSeconds()).isEqualTo(7800); + assertThat(response.getActivities().get(1).getName()).isEqualTo("Lunch Walk"); + assertThat(response.getActivities().get(1).isImported()).isTrue(); + assertThat(response.getActivities().get(1).getFitPubActivityId()).isEqualTo(existingActivityId); verify(throttledService).pauseBeforeNextPageRequest(); server.verify(); @@ -219,11 +219,11 @@ class KomootImportServiceTest { ), userId); - assertThat(response.totalCount()).isEqualTo(2); - assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L); - assertThat(response.activities().get(0).imported()).isFalse(); - assertThat(response.activities().get(1).imported()).isTrue(); - assertThat(response.activities().get(1).fitPubActivityId()).isEqualTo(existingActivityId); + assertThat(response.getTotalCount()).isEqualTo(2); + assertThat(response.getActivities()).extracting("id").containsExactly(1002L, 1003L); + assertThat(response.getActivities().get(0).isImported()).isFalse(); + assertThat(response.getActivities().get(1).isImported()).isTrue(); + assertThat(response.getActivities().get(1).getFitPubActivityId()).isEqualTo(existingActivityId); server.verify(); } @@ -296,9 +296,9 @@ class KomootImportServiceTest { userId ); - assertThat(response.importedActivityId()).isEqualTo(importedActivityId); - assertThat(response.importedKomootActivityId()).isEqualTo(2880957035L); - assertThat(response.status()).isEqualTo("IMPORTED"); + 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"); @@ -326,9 +326,9 @@ class KomootImportServiceTest { userId ); - assertThat(response.importedActivityId()).isEqualTo(existingActivityId); - assertThat(response.importedKomootActivityId()).isEqualTo(3002L); - assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED"); + assertThat(response.getImportedActivityId()).isEqualTo(existingActivityId); + assertThat(response.getImportedKomootActivityId()).isEqualTo(3002L); + assertThat(response.getStatus()).isEqualTo("SKIPPED_ALREADY_IMPORTED"); } @Test @@ -386,8 +386,8 @@ class KomootImportServiceTest { userId ); - assertThat(response.importedActivityId()).isEqualTo(importedActivityId); - assertThat(response.status()).isEqualTo("IMPORTED"); + assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId); + assertThat(response.getStatus()).isEqualTo("IMPORTED"); assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER); verify(throttledService).pauseBetweenDetailAndGpxRequest(); From 3135a36679d7e6f9b1c389d3152b53206416cee9 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 11:12:18 +0200 Subject: [PATCH 2/2] refactor(komoot): move import mapping to separate table Signed-off-by: Marcus Fihlon --- .../fitpub/model/entity/Activity.java | 7 --- .../fitpub/model/entity/KomootImport.java | 46 +++++++++++++++++++ .../fitpub/repository/ActivityRepository.java | 28 ----------- .../repository/KomootImportRepository.java | 33 +++++++++++++ .../fitpub/service/KomootImportService.java | 21 ++++++--- ...__add_komoot_activity_id_to_activities.sql | 15 ------ .../V32__create_komoot_imports_table.sql | 23 ++++++++++ .../service/KomootImportServiceTest.java | 33 +++++++------ 8 files changed, 136 insertions(+), 70 deletions(-) create mode 100644 src/main/java/net/javahippie/fitpub/model/entity/KomootImport.java create mode 100644 src/main/java/net/javahippie/fitpub/repository/KomootImportRepository.java delete mode 100644 src/main/resources/db/migration/V32__add_komoot_activity_id_to_activities.sql create mode 100644 src/main/resources/db/migration/V32__create_komoot_imports_table.sql 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();