From 7ca09f0f27c9f78cffa21e140545bc2b6ef87dd4 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 12:18:02 +0200 Subject: [PATCH 01/23] feat(komoot): add completed activities preview import flow Signed-off-by: Marcus Fihlon --- .../fitpub/config/SecurityConfig.java | 2 + .../controller/KomootImportController.java | 61 +++++ .../KomootImportViewController.java | 18 ++ .../model/dto/KomootActivitiesResponse.java | 13 + .../model/dto/KomootActivitySummaryDTO.java | 20 ++ .../fitpub/model/dto/KomootImportRequest.java | 24 ++ .../fitpub/service/KomootImportService.java | 171 ++++++++++++ src/main/resources/application.yml | 3 + .../templates/activities/komoot.html | 246 ++++++++++++++++++ src/main/resources/templates/layout.html | 5 + .../service/KomootImportServiceTest.java | 108 ++++++++ 11 files changed, 671 insertions(+) create mode 100644 src/main/java/net/javahippie/fitpub/controller/KomootImportController.java create mode 100644 src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java create mode 100644 src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java create mode 100644 src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java create mode 100644 src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java create mode 100644 src/main/java/net/javahippie/fitpub/service/KomootImportService.java create mode 100644 src/main/resources/templates/activities/komoot.html create mode 100644 src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java diff --git a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java index 3847ba4..aff2bae 100644 --- a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java +++ b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java @@ -87,6 +87,7 @@ public class SecurityConfig { // Protected view pages - require authentication .requestMatchers("/activities", "/activities/upload").authenticated() + .requestMatchers("/komoot-import").authenticated() .requestMatchers("/profile", "/profile/**", "/settings").authenticated() .requestMatchers("/notifications").authenticated() .requestMatchers("/analytics", "/analytics/**").authenticated() @@ -149,6 +150,7 @@ public class SecurityConfig { // Protected endpoints - Batch Import API .requestMatchers("/api/batch-import/**").authenticated() + .requestMatchers("/api/komoot-import/**").authenticated() // Protected endpoints - Privacy Zones API .requestMatchers("/api/privacy-zones/**").authenticated() diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java new file mode 100644 index 0000000..3a2e621 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java @@ -0,0 +1,61 @@ +package net.javahippie.fitpub.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; +import net.javahippie.fitpub.model.dto.KomootImportRequest; +import net.javahippie.fitpub.service.KomootImportService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST API for previewing completed Komoot activities. + */ +@RestController +@RequestMapping("/api/komoot-import") +@RequiredArgsConstructor +@Slf4j +public class KomootImportController { + + private final KomootImportService komootImportService; + + @PostMapping("/activities") + public ResponseEntity listActivities( + @Valid @RequestBody KomootImportRequest request, + Authentication authentication + ) { + log.info("User {} requested Komoot activity preview for Komoot ID {}", + authentication.getName(), request.userId()); + KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request); + return ResponseEntity.ok(response); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException e) { + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid request") + .orElse("Invalid request"); + return ResponseEntity.badRequest().body(new ErrorResponse(message)); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleIllegalState(IllegalStateException e) { + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse(e.getMessage())); + } + + record ErrorResponse(String error) {} +} diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java new file mode 100644 index 0000000..d3ad006 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java @@ -0,0 +1,18 @@ +package net.javahippie.fitpub.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Serves the Komoot import preview page. + */ +@Controller +public class KomootImportViewController { + + @GetMapping("/komoot-import") + public String komootImportPage(Model model) { + model.addAttribute("pageTitle", "Komoot Import"); + return "activities/komoot"; + } +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java new file mode 100644 index 0000000..c9223d7 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java @@ -0,0 +1,13 @@ +package net.javahippie.fitpub.model.dto; + +import java.util.List; + +/** + * Response payload for the Komoot import preview. + */ +public record KomootActivitiesResponse( + String userId, + int totalCount, + List activities +) { +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java new file mode 100644 index 0000000..830f838 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java @@ -0,0 +1,20 @@ +package net.javahippie.fitpub.model.dto; + +import java.time.OffsetDateTime; + +/** + * Reduced activity representation returned by the Komoot import preview. + */ +public record KomootActivitySummaryDTO( + long id, + String name, + String sport, + String status, + String type, + OffsetDateTime date, + Double distanceMeters, + Integer durationSeconds, + Integer timeInMotionSeconds, + Double elevationUp +) { +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java new file mode 100644 index 0000000..1851749 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java @@ -0,0 +1,24 @@ +package net.javahippie.fitpub.model.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/** + * Request payload for fetching completed activities from Komoot. + * + *

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

+ */ +public record KomootImportRequest( + @NotBlank + @Email + String email, + + @NotBlank + String password, + + @NotBlank + @Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only") + String userId +) { +} diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java new file mode 100644 index 0000000..cb483ed --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -0,0 +1,171 @@ +package net.javahippie.fitpub.service; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; +import net.javahippie.fitpub.model.dto.KomootActivitySummaryDTO; +import net.javahippie.fitpub.model.dto.KomootImportRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +/** + * Fetches a temporary preview of completed Komoot activities for an authenticated FitPub user. + * + *

Komoot does not expose a public API for this use case. This service currently talks to the + * same web API endpoints used by the Komoot website and therefore depends on their current + * behavior.

+ */ +@Service +@RequiredArgsConstructor +@Slf4j +public class KomootImportService { + + private static final int PAGE_SIZE = 100; + private static final String KOMOOT_LANGUAGE = "en"; + + private final RestTemplate restTemplate; + + @Value("${fitpub.komoot.base-url:https://www.komoot.com}") + private String komootBaseUrl; + + public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request) { + List activities = new ArrayList<>(); + + URI nextUri = buildInitialUri(request.userId()); + HttpEntity httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); + + try { + while (nextUri != null) { + ResponseEntity response = restTemplate.exchange( + nextUri, HttpMethod.GET, httpEntity, JsonNode.class); + + JsonNode root = response.getBody(); + if (root == null) { + throw new IllegalStateException("Komoot returned an empty response body."); + } + extractActivities(root, activities); + nextUri = extractNextUri(root); + } + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new IllegalArgumentException("Komoot login failed. Check email, password and Komoot ID.", e); + } catch (HttpClientErrorException.NotFound e) { + throw new IllegalArgumentException("Komoot user or activities endpoint not found for the given Komoot ID.", e); + } catch (RestClientException e) { + throw new IllegalStateException("Failed to reach Komoot. The remote service may be unavailable.", e); + } catch (Exception e) { + 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); + } + + private URI buildInitialUri(String userId) { + String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; + return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + userId + "/tours/") + .queryParam("type", "tour_recorded") + .queryParam("status", "private") + .queryParam("name", "") + .queryParam("hl", KOMOOT_LANGUAGE) + .queryParam("sort_field", "date") + .queryParam("sort_direction", "desc") + .queryParam("page", 0) + .queryParam("limit", PAGE_SIZE) + .build() + .toUri(); + } + + private HttpHeaders buildHeaders(String email, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(List.of(MediaType.parseMediaType("application/hal+json"), MediaType.APPLICATION_JSON)); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAcceptLanguageAsLocales(List.of(java.util.Locale.ENGLISH)); + headers.set(HttpHeaders.USER_AGENT, + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"); + headers.set(HttpHeaders.AUTHORIZATION, basicAuth(email, password)); + return headers; + } + + private String basicAuth(String email, String password) { + String credentials = email + ":" + password; + String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + return "Basic " + encoded; + } + + private void extractActivities(JsonNode root, List activities) { + JsonNode tours = root.path("_embedded").path("tours"); + if (!tours.isArray()) { + return; + } + + for (JsonNode tour : tours) { + activities.add(new KomootActivitySummaryDTO( + tour.path("id").asLong(), + nullableText(tour, "name"), + nullableText(tour, "sport"), + nullableText(tour, "status"), + nullableText(tour, "type"), + parseDate(tour.path("date").asText(null)), + nullableDouble(tour, "distance"), + nullableInteger(tour, "duration"), + nullableInteger(tour, "time_in_motion"), + nullableDouble(tour, "elevation_up") + )); + } + } + + private URI extractNextUri(JsonNode root) { + String nextHref = root.path("_links").path("next").path("href").asText(null); + if (nextHref == null || nextHref.isBlank()) { + return null; + } + + if (nextHref.startsWith("http://") || nextHref.startsWith("https://")) { + return URI.create(nextHref); + } + + String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; + String normalizedNextHref = nextHref.startsWith("/") ? nextHref : "/" + nextHref; + return URI.create(normalizedBaseUrl + normalizedNextHref); + } + + private OffsetDateTime parseDate(String value) { + if (value == null || value.isBlank()) { + return null; + } + return OffsetDateTime.parse(value); + } + + private String nullableText(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asText(); + } + + private Double nullableDouble(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asDouble(); + } + + private Integer nullableInteger(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asInt(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3a4e4e5..a6b7f92 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -104,6 +104,9 @@ fitpub: enabled: ${WEATHER_ENABLED:false} api-key: ${OPENWEATHERMAP_API_KEY:} + komoot: + base-url: ${KOMOOT_BASE_URL:https://www.komoot.com} + # Logging configuration logging: level: diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html new file mode 100644 index 0000000..5c354f3 --- /dev/null +++ b/src/main/resources/templates/activities/komoot.html @@ -0,0 +1,246 @@ + + + + + Komoot Import + + + +
+
+
+

+ + Komoot Import +

+ +
+
Phase 1: Preview only
+
+ Your Komoot credentials are only used for this request and are not stored in FitPub. +
+
+ Komoot does not provide a public API for this flow. This preview currently depends on the + same web API endpoints used by the Komoot website and may stop working if Komoot changes them. +
+
+ + + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
You can find the Komoot ID in your Komoot account settings.
+
+
+ +
+ +
+
+
+
+ +
+
+

+ + Completed Activities +

+ 0 +
+ +
+ + + + + + + + + + + + +
NameDateSportDistanceDurationElevation
+
+
+
+
+
+ + + + + + diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html index 2ddd461..f78e5d6 100644 --- a/src/main/resources/templates/layout.html +++ b/src/main/resources/templates/layout.html @@ -97,6 +97,11 @@ Batch Import +
  • + + Komoot Import + +
  • diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java new file mode 100644 index 0000000..3b1f2fe --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -0,0 +1,108 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; +import net.javahippie.fitpub.model.dto.KomootImportRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class KomootImportServiceTest { + + private RestTemplate restTemplate; + private MockRestServiceServer server; + private KomootImportService service; + + @BeforeEach + void setUp() { + restTemplate = new RestTemplate(); + server = MockRestServiceServer.bindTo(restTemplate).build(); + service = new KomootImportService(restTemplate); + ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com"); + } + + @Test + void shouldFetchAndMergePagedCompletedActivities() { + String authHeader = "Basic " + Base64.getEncoder() + .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "_embedded": { + "tours": [ + { + "id": 1001, + "name": "Evening Ride", + "sport": "touringbicycle", + "status": "private", + "type": "tour_recorded", + "date": "2026-04-27T18:15:00+02:00", + "distance": 42350.4, + "duration": 8120, + "time_in_motion": 7800, + "elevation_up": 520.2 + } + ] + }, + "_links": { + "next": { + "href": "/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100" + } + } + } + """, MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "_embedded": { + "tours": [ + { + "id": 1002, + "name": "Lunch Walk", + "sport": "hike", + "status": "private", + "type": "tour_recorded", + "date": "2026-04-26T12:30:00+02:00", + "distance": 5120.0, + "duration": 3600, + "time_in_motion": 3400, + "elevation_up": 75.0 + } + ] + }, + "_links": {} + } + """, MediaType.APPLICATION_JSON)); + + KomootActivitiesResponse response = service.fetchCompletedActivities( + new KomootImportRequest("user@example.com", "secret", "123456")); + + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.activities()).hasSize(2); + assertThat(response.activities().get(0).id()).isEqualTo(1001L); + assertThat(response.activities().get(0).timeInMotionSeconds()).isEqualTo(7800); + assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk"); + + server.verify(); + } +} From 0cea88d033887e9e50ba7d3134665c29304e3a8c Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 12:31:18 +0200 Subject: [PATCH 02/23] feat(db): add komoot activity id for import deduplication Signed-off-by: Marcus Fihlon --- .../V32__add_komoot_activity_id_to_activities.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/resources/db/migration/V32__add_komoot_activity_id_to_activities.sql 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 new file mode 100644 index 0000000..24ef05f --- /dev/null +++ b/src/main/resources/db/migration/V32__add_komoot_activity_id_to_activities.sql @@ -0,0 +1,15 @@ +-- 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'; From 803caf06b10043cb4ee2e8fbebda2629a41c8b9e Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 13:24:57 +0200 Subject: [PATCH 03/23] feat(komoot): import first new activity via GPX and override metadata Signed-off-by: Marcus Fihlon --- .../controller/KomootImportController.java | 26 +- .../fitpub/model/entity/Activity.java | 7 + .../fitpub/repository/ActivityRepository.java | 7 + .../fitpub/service/KomootImportService.java | 241 +++++++++++++++++- .../templates/activities/komoot.html | 92 ++++++- .../service/KomootImportServiceTest.java | 181 ++++++++++++- 6 files changed, 542 insertions(+), 12 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java index 3a2e621..eca692d 100644 --- a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java @@ -4,7 +4,9 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; 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.repository.UserRepository; import net.javahippie.fitpub.service.KomootImportService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -16,8 +18,10 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; + /** - * REST API for previewing completed Komoot activities. + * REST API for loading and importing Komoot activities. */ @RestController @RequestMapping("/api/komoot-import") @@ -26,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController; public class KomootImportController { private final KomootImportService komootImportService; + private final UserRepository userRepository; @PostMapping("/activities") public ResponseEntity listActivities( @@ -38,6 +43,25 @@ public class KomootImportController { return ResponseEntity.ok(response); } + @PostMapping("/activities/import-first") + public ResponseEntity importFirstNewActivity( + @Valid @RequestBody KomootImportRequest request, + Authentication authentication + ) { + UUID fitPubUserId = userRepository.findByUsername(authentication.getName()) + .orElseThrow(() -> new IllegalArgumentException("Authenticated user not found")) + .getId(); + + log.info("User {} requested Komoot import for the first new activity", + authentication.getName()); + + KomootImportExecutionResponse response = komootImportService.importFirstNewActivity( + request, + fitPubUserId + ); + return ResponseEntity.ok(response); + } + @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgument(IllegalArgumentException e) { return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); 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 045bd7a..6b2fb52 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/Activity.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/Activity.java @@ -117,6 +117,13 @@ 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/repository/ActivityRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java index 55483a2..555a6ce 100644 --- a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java @@ -23,6 +23,13 @@ public interface ActivityRepository extends JpaRepository { @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); + /** * Find all activities for a specific user. * diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index cb483ed..86159fd 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -5,7 +5,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; 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.repository.ActivityRepository; +import net.javahippie.fitpub.util.ByteArrayMultipartFile; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -23,7 +27,11 @@ import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.Base64; +import java.util.Comparator; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.UUID; /** * Fetches a temporary preview of completed Komoot activities for an authenticated FitPub user. @@ -39,8 +47,10 @@ public class KomootImportService { private static final int PAGE_SIZE = 100; private static final String KOMOOT_LANGUAGE = "en"; - private final RestTemplate restTemplate; + private final ActivityRepository activityRepository; + private final ActivityFileService activityFileService; + private final ActivityPostProcessingService activityPostProcessingService; @Value("${fitpub.komoot.base-url:https://www.komoot.com}") private String komootBaseUrl; @@ -77,6 +87,68 @@ public class KomootImportService { return new KomootActivitiesResponse(request.userId(), activities.size(), activities); } + public KomootImportExecutionResponse importFirstNewActivity(KomootImportRequest request, UUID fitPubUserId) { + ImportCandidateContext context = buildImportCandidateContext(request, fitPubUserId); + if (context.candidate() == null) { + return new KomootImportExecutionResponse( + null, + null, + context.importedKomootActivityIds().size(), + context.activities().size(), + "No new Komoot activities found to import." + ); + } + + KomootActivitySummaryDTO candidate = context.candidate(); + JsonNode details = fetchActivityDetails(request, candidate.id()); + byte[] gpxData = fetchActivityGpx(request, candidate.id()); + + ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile( + "file", + "komoot-" + candidate.id() + ".gpx", + "application/gpx+xml", + gpxData + ); + + Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status")); + String mappedTitle = firstNonBlank(nullableText(details, "name"), candidate.name(), "Komoot Activity " + candidate.id()); + String mappedDescription = nullableText(details, "description"); + Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport")); + + Activity importedActivity = activityFileService.processActivityFile( + gpxFile, + fitPubUserId, + mappedTitle, + mappedDescription, + mappedVisibility + ); + + importedActivity.setKomootActivityId(candidate.id()); + importedActivity.setTitle(mappedTitle); + importedActivity.setDescription(mappedDescription); + importedActivity.setVisibility(mappedVisibility); + importedActivity.setActivityType(mappedActivityType); + + importedActivity = activityRepository.save(importedActivity); + activityPostProcessingService.processActivityAsync(importedActivity.getId(), fitPubUserId); + + log.info( + "Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}", + candidate.id(), + importedActivity.getId(), + importedActivity.getVisibility(), + importedActivity.getActivityType() + ); + + return new KomootImportExecutionResponse( + importedActivity.getId(), + candidate.id(), + context.importedKomootActivityIds().size() + 1, + context.activities().size(), + "Imported Komoot activity " + candidate.id() + " into FitPub activity " + importedActivity.getId() + ); + } + private URI buildInitialUri(String userId) { String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + userId + "/tours/") @@ -92,6 +164,25 @@ public class KomootImportService { .toUri(); } + private URI buildDetailUri(long activityId) { + String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; + return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/tours/" + activityId) + .queryParam("hl", KOMOOT_LANGUAGE) + .build() + .toUri(); + } + + private List buildGpxCandidateUris(long activityId) { + String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; + String apiBaseUrl = normalizedBaseUrl.replace("://www.komoot.com", "://api.komoot.de"); + + return List.of( + URI.create(normalizedBaseUrl + "/api/v007/tours/" + activityId + ".gpx"), + URI.create(apiBaseUrl + "/v007/tours/" + activityId + ".gpx"), + URI.create(normalizedBaseUrl + "/tour/" + activityId + ".gpx") + ); + } + private HttpHeaders buildHeaders(String email, String password) { HttpHeaders headers = new HttpHeaders(); headers.setAccept(List.of(MediaType.parseMediaType("application/hal+json"), MediaType.APPLICATION_JSON)); @@ -104,6 +195,16 @@ public class KomootImportService { return headers; } + private HttpHeaders buildGpxHeaders(String email, String password) { + HttpHeaders headers = buildHeaders(email, password); + headers.setAccept(List.of( + MediaType.parseMediaType("application/gpx+xml"), + MediaType.APPLICATION_XML, + MediaType.TEXT_XML + )); + return headers; + } + private String basicAuth(String email, String password) { String credentials = email + ":" + password; String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); @@ -132,6 +233,144 @@ public class KomootImportService { } } + private JsonNode fetchActivityDetails(KomootImportRequest request, long activityId) { + try { + ResponseEntity response = restTemplate.exchange( + buildDetailUri(activityId), + HttpMethod.GET, + new HttpEntity<>(buildHeaders(request.email(), request.password())), + JsonNode.class + ); + + JsonNode body = response.getBody(); + if (body == null) { + throw new IllegalStateException("Komoot returned an empty activity detail response."); + } + return body; + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new IllegalArgumentException("Komoot login failed while loading activity details.", e); + } catch (HttpClientErrorException.NotFound e) { + throw new IllegalArgumentException("Komoot activity details could not be found.", e); + } catch (RestClientException e) { + throw new IllegalStateException("Failed to reach Komoot while loading activity details.", e); + } + } + + private byte[] fetchActivityGpx(KomootImportRequest request, long activityId) { + HttpEntity httpEntity = new HttpEntity<>(buildGpxHeaders(request.email(), request.password())); + List candidateUris = buildGpxCandidateUris(activityId); + Exception lastException = null; + + for (URI candidateUri : candidateUris) { + try { + ResponseEntity response = restTemplate.exchange( + candidateUri, + HttpMethod.GET, + httpEntity, + byte[].class + ); + + byte[] body = response.getBody(); + if (body == null || body.length == 0) { + throw new IllegalStateException("Komoot returned an empty GPX response."); + } + + String gpxText = new String(body, StandardCharsets.UTF_8); + if (!gpxText.contains(" importedKomootActivityIds = new HashSet<>( + activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); + + List activities = new ArrayList<>(fetchCompletedActivities(request).activities()); + activities.sort(Comparator.comparing( + KomootActivitySummaryDTO::date, + Comparator.nullsLast(Comparator.reverseOrder()) + )); + + KomootActivitySummaryDTO candidate = activities.stream() + .filter(activity -> !importedKomootActivityIds.contains(activity.id())) + .findFirst() + .orElse(null); + + return new ImportCandidateContext(importedKomootActivityIds, activities, candidate); + } + + private Activity.Visibility mapVisibility(String komootStatus) { + if (komootStatus == null) { + return Activity.Visibility.PRIVATE; + } + + return switch (komootStatus.toLowerCase(java.util.Locale.ROOT)) { + case "public" -> Activity.Visibility.PUBLIC; + case "friends", "followers", "close_friends" -> Activity.Visibility.FOLLOWERS; + default -> Activity.Visibility.PRIVATE; + }; + } + + private Activity.ActivityType mapKomootSportToActivityType(String komootSport) { + if (komootSport == null || komootSport.isBlank()) { + return Activity.ActivityType.OTHER; + } + + return switch (komootSport.toLowerCase(java.util.Locale.ROOT)) { + case "hike" -> Activity.ActivityType.HIKE; + case "walk" -> Activity.ActivityType.WALK; + case "run", "trailrunning", "jogging" -> Activity.ActivityType.RUN; + case "touringbicycle", "road_bike", "bike", "bicycle", "gravel", "mtb", "mtb_easy", "mtb_advanced", "ebike" -> + Activity.ActivityType.RIDE; + case "alpine_ski" -> Activity.ActivityType.ALPINE_SKI; + case "backcountry_ski" -> Activity.ActivityType.BACKCOUNTRY_SKI; + case "nordic_ski", "cross_country_ski" -> Activity.ActivityType.NORDIC_SKI; + case "snowboard" -> Activity.ActivityType.SNOWBOARD; + case "swim" -> Activity.ActivityType.SWIM; + case "rowing" -> Activity.ActivityType.ROWING; + case "kayak", "kayaking" -> Activity.ActivityType.KAYAKING; + case "canoe", "canoeing" -> Activity.ActivityType.CANOEING; + case "inline_skate", "inline_skating" -> Activity.ActivityType.INLINE_SKATING; + case "rock_climbing" -> Activity.ActivityType.ROCK_CLIMBING; + case "mountaineering" -> Activity.ActivityType.MOUNTAINEERING; + case "yoga" -> Activity.ActivityType.YOGA; + case "workout", "gym" -> Activity.ActivityType.WORKOUT; + default -> Activity.ActivityType.OTHER; + }; + } + + private String firstNonBlank(String first, String second, String fallback) { + if (first != null && !first.isBlank()) { + return first; + } + if (second != null && !second.isBlank()) { + return second; + } + return fallback; + } + + private record ImportCandidateContext( + Set importedKomootActivityIds, + List activities, + KomootActivitySummaryDTO candidate + ) { + } + private URI extractNextUri(JsonNode root) { String nextHref = root.path("_links").path("next").path("href").asText(null); if (nextHref == null || nextHref.isBlank()) { diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index 5c354f3..ccdab09 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -18,12 +18,12 @@
    -
    Phase 1: Preview only
    +
    Komoot Import
    Your Komoot credentials are only used for this request and are not stored in FitPub.
    - Komoot does not provide a public API for this flow. This preview currently depends on the + Komoot does not provide a public API for this flow. This import currently depends on the same web API endpoints used by the Komoot website and may stop working if Komoot changes them.
    @@ -49,7 +49,16 @@ -
    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    Both dates must be set together. Inclusive, day-based filter.
    +
    @@ -225,13 +238,21 @@ function buildPayload() { const formData = new FormData(form); + const startDate = formData.get('startDate'); + const endDate = formData.get('endDate'); return { email: formData.get('email'), password: formData.get('password'), - userId: formData.get('userId') + userId: formData.get('userId'), + startDate: startDate || null, + endDate: endDate || null }; } + function hasIncompleteDateRange(payload) { + return Boolean(payload.startDate) !== Boolean(payload.endDate); + } + form.addEventListener('submit', async function(event) { event.preventDefault(); clearError(); @@ -239,6 +260,10 @@ resultsSection.classList.add('d-none'); const payload = buildPayload(); + if (hasIncompleteDateRange(payload)) { + showError('Start date and end date must either both be set or both be empty.'); + return; + } setLoading(true); @@ -280,6 +305,10 @@ resetAlertToError(); const payload = buildPayload(); + if (hasIncompleteDateRange(payload)) { + showError('Start date and end date must either both be set or both be empty.'); + return; + } setImportLoading(true); try { diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index 23fd527..e0afbe1 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -6,6 +6,7 @@ import net.javahippie.fitpub.model.dto.KomootImportRequest; import net.javahippie.fitpub.model.entity.Activity; import net.javahippie.fitpub.repository.ActivityRepository; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; @@ -16,11 +17,14 @@ import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestTemplate; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; import java.util.Base64; import java.util.List; +import java.util.TimeZone; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -39,9 +43,12 @@ class KomootImportServiceTest { private ActivityRepository activityRepository; private ActivityFileService activityFileService; private ActivityPostProcessingService activityPostProcessingService; + private TimeZone originalTimeZone; @BeforeEach void setUp() { + originalTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Zurich")); restTemplate = new RestTemplate(); server = MockRestServiceServer.bindTo(restTemplate).build(); activityRepository = mock(ActivityRepository.class); @@ -51,12 +58,17 @@ class KomootImportServiceTest { ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com"); } + @AfterEach + void tearDown() { + TimeZone.setDefault(originalTimeZone); + } + @Test void shouldFetchAndMergePagedCompletedActivities() { String authHeader = "Basic " + Base64.getEncoder() .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100")) + 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")) .andExpect(method(HttpMethod.GET)) .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) .andRespond(withSuccess(""" @@ -111,7 +123,7 @@ class KomootImportServiceTest { """, MediaType.APPLICATION_JSON)); KomootActivitiesResponse response = service.fetchCompletedActivities( - new KomootImportRequest("user@example.com", "secret", "123456")); + new KomootImportRequest("user@example.com", "secret", "123456", null, null)); assertThat(response.totalCount()).isEqualTo(2); assertThat(response.activities()).hasSize(2); @@ -122,6 +134,69 @@ class KomootImportServiceTest { server.verify(); } + @Test + @DisplayName("Should filter loaded Komoot activities by inclusive date range") + void shouldFilterCompletedActivitiesByInclusiveDateRange() { + String authHeader = "Basic " + Base64.getEncoder() + .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); + + 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")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "_embedded": { + "tours": [ + { + "id": 1002, + "name": "Included Start", + "sport": "hike", + "status": "private", + "type": "tour_recorded", + "date": "2026-04-26T00:00:00+02:00" + }, + { + "id": 1003, + "name": "Included End", + "sport": "run", + "status": "private", + "type": "tour_recorded", + "date": "2026-04-27T23:59:59+02:00" + } + ] + }, + "_links": {} + } + """, MediaType.APPLICATION_JSON)); + + KomootActivitiesResponse response = service.fetchCompletedActivities( + new KomootImportRequest( + "user@example.com", + "secret", + "123456", + LocalDate.of(2026, 4, 26), + LocalDate.of(2026, 4, 27) + )); + + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L); + + server.verify(); + } + + @Test + @DisplayName("Should reject incomplete Komoot date range") + void shouldRejectIncompleteDateRange() { + assertThatThrownBy(() -> new KomootImportRequest( + "user@example.com", + "secret", + "123456", + LocalDate.of(2026, 4, 27), + null + )).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Start date and end date must either both be set or both be empty."); + } + @Test @DisplayName("Should import newest not-yet-imported Komoot activity via GPX and override metadata") void shouldImportNewestNotYetImportedActivity() { @@ -132,7 +207,7 @@ class KomootImportServiceTest { when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100")) + 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")) .andExpect(method(HttpMethod.GET)) .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) .andRespond(withSuccess(""" @@ -190,7 +265,7 @@ class KomootImportServiceTest { when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest("user@example.com", "secret", "123456"), + new KomootImportRequest("user@example.com", "secret", "123456", null, null), userId ); @@ -206,6 +281,91 @@ class KomootImportServiceTest { server.verify(); } + @Test + @DisplayName("Should respect date range when choosing Komoot import candidate") + void shouldRespectDateRangeWhenImportingFirstNewActivity() { + String authHeader = "Basic " + Base64.getEncoder() + .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); + UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + UUID importedActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); + + 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-26T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "_embedded": { + "tours": [ + { + "id": 3002, + "name": "Inside Range Candidate", + "sport": "mtb_easy", + "status": "public", + "type": "tour_recorded", + "date": "2026-04-27T18:15:00+02:00" + } + ] + }, + "_links": {} + } + """, MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002?hl=en")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "id": "3002", + "name": "Inside Range Candidate", + "description": "Imported from Komoot", + "status": "public", + "sport": "mtb_easy" + } + """, MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002.gpx")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + + + Inside Range Candidate + + """, MediaType.APPLICATION_XML)); + + Activity importedActivity = Activity.builder() + .id(importedActivityId) + .userId(userId) + .activityType(Activity.ActivityType.OTHER) + .title("GPX Title") + .description(null) + .visibility(Activity.Visibility.PRIVATE) + .sourceFileFormat("GPX") + .build(); + + when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); + when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + KomootImportExecutionResponse response = service.importFirstNewActivity( + new KomootImportRequest( + "user@example.com", + "secret", + "123456", + LocalDate.of(2026, 4, 27), + LocalDate.of(2026, 4, 27) + ), + userId + ); + + assertThat(response.importedKomootActivityId()).isEqualTo(3002L); + assertThat(importedActivity.getKomootActivityId()).isEqualTo(3002L); + + verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); + server.verify(); + } + @Test @DisplayName("Should fall back to OTHER when Komoot sport cannot be mapped") void shouldFallbackToOtherForUnknownKomootSport() { @@ -216,7 +376,7 @@ class KomootImportServiceTest { when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100")) + 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")) .andExpect(method(HttpMethod.GET)) .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) .andRespond(withSuccess(""" @@ -274,7 +434,7 @@ class KomootImportServiceTest { when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest("user@example.com", "secret", "123456"), + new KomootImportRequest("user@example.com", "secret", "123456", null, null), userId ); From 6d894265848131d5f1ba783341e07a9d0755925c Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 14:10:06 +0200 Subject: [PATCH 05/23] feat(komoot): refine activity list for import status and mapped types Signed-off-by: Marcus Fihlon --- .../controller/KomootImportController.java | 6 ++++- .../model/dto/KomootActivitySummaryDTO.java | 4 ++- .../fitpub/service/KomootImportService.java | 17 +++++++----- .../templates/activities/komoot.html | 26 ++++++++++++------- .../service/KomootImportServiceTest.java | 16 ++++++++++-- 5 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java index eca692d..4ad57a0 100644 --- a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java @@ -37,9 +37,13 @@ public class KomootImportController { @Valid @RequestBody KomootImportRequest request, Authentication authentication ) { + UUID fitPubUserId = userRepository.findByUsername(authentication.getName()) + .orElseThrow(() -> new IllegalArgumentException("Authenticated user not found")) + .getId(); + log.info("User {} requested Komoot activity preview for Komoot ID {}", authentication.getName(), request.userId()); - KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request); + KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request, fitPubUserId); return ResponseEntity.ok(response); } 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 830f838..d551b49 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java @@ -9,12 +9,14 @@ 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 + Double elevationUp, + boolean imported ) { } diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index d37ab78..47e06f3 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -61,8 +61,10 @@ public class KomootImportService { @Value("${fitpub.komoot.base-url:https://www.komoot.com}") private String komootBaseUrl; - public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request) { + public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request, UUID fitPubUserId) { List activities = new ArrayList<>(); + Set importedKomootActivityIds = new HashSet<>( + activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); URI nextUri = buildInitialUri(request); HttpEntity httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); @@ -76,7 +78,7 @@ public class KomootImportService { if (root == null) { throw new IllegalStateException("Komoot returned an empty response body."); } - extractActivities(root, activities); + extractActivities(root, activities, importedKomootActivityIds); nextUri = extractNextUri(root); } } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { @@ -223,24 +225,27 @@ public class KomootImportService { return "Basic " + encoded; } - private void extractActivities(JsonNode root, List activities) { + private void extractActivities(JsonNode root, List activities, Set importedKomootActivityIds) { JsonNode tours = root.path("_embedded").path("tours"); if (!tours.isArray()) { return; } for (JsonNode tour : tours) { + long activityId = tour.path("id").asLong(); activities.add(new KomootActivitySummaryDTO( - tour.path("id").asLong(), + activityId, nullableText(tour, "name"), nullableText(tour, "sport"), + mapKomootSportToActivityType(nullableText(tour, "sport")).name(), nullableText(tour, "status"), nullableText(tour, "type"), parseDate(tour.path("date").asText(null)), nullableDouble(tour, "distance"), nullableInteger(tour, "duration"), nullableInteger(tour, "time_in_motion"), - nullableDouble(tour, "elevation_up") + nullableDouble(tour, "elevation_up"), + importedKomootActivityIds.contains(activityId) )); } } @@ -312,7 +317,7 @@ public class KomootImportService { Set importedKomootActivityIds = new HashSet<>( activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); - List activities = new ArrayList<>(fetchCompletedActivities(request).activities()); + List activities = new ArrayList<>(fetchCompletedActivities(request, fitPubUserId).activities()); activities.sort(Comparator.comparing( KomootActivitySummaryDTO::date, Comparator.nullsLast(Comparator.reverseOrder()) diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index c99e6db..2bfdbfd 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -74,7 +74,7 @@ +
    @@ -130,6 +130,11 @@ const importFirstBtn = document.getElementById('importFirstBtn'); const importFirstText = document.getElementById('importFirstText'); const importFirstSpinner = document.getElementById('importFirstSpinner'); + let currentActivities = []; + + function updateImportButtonState() { + importFirstBtn.disabled = currentActivities.length === 0; + } function setLoading(loading) { loadActivitiesBtn.disabled = loading; @@ -216,6 +221,23 @@ return `${escapeHtml(activityType)}`; } + function renderImportStatus(activity) { + if (activity.uiImportStatus === 'importing') { + return ''; + } + + if (activity.uiImportStatus === 'error') { + const title = escapeHtml(activity.uiImportError || 'Import failed'); + return ``; + } + + if (activity.imported) { + return ''; + } + + return ''; + } + function renderActivities(activities) { resultCount.textContent = activities.length; @@ -233,11 +255,7 @@ ${formatDistance(activity.distanceMeters)} ${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)} ${formatElevation(activity.elevationUp)} - - ${activity.imported - ? '' - : ''} - + ${renderImportStatus(activity)} `).join(''); @@ -257,6 +275,16 @@ }; } + function buildImportPayload(activityId) { + const payload = buildPayload(); + return { + email: payload.email, + password: payload.password, + userId: payload.userId, + activityId: activityId + }; + } + function hasIncompleteDateRange(payload) { return Boolean(payload.startDate) !== Boolean(payload.endDate); } @@ -274,6 +302,8 @@ } setLoading(true); + currentActivities = []; + updateImportButtonState(); try { const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', { @@ -293,8 +323,13 @@ } const data = await response.json(); - renderActivities(data.activities || []); - form.querySelector('#password').value = ''; + currentActivities = (data.activities || []).map(activity => ({ + ...activity, + uiImportStatus: null, + uiImportError: null + })); + updateImportButtonState(); + renderActivities(currentActivities); } catch (error) { let message = error instanceof Error ? error.message : 'Failed to load Komoot activities.'; @@ -317,29 +352,66 @@ showError('Start date and end date must either both be set or both be empty.'); return; } + if (currentActivities.length === 0) { + showError('Load Komoot activities before starting the import.'); + return; + } setImportLoading(true); try { - const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import-first', { - method: 'POST', - body: payload - }); + const activitiesToImport = currentActivities + .filter(activity => !activity.imported) + .sort((left, right) => new Date(left.date).getTime() - new Date(right.date).getTime()); - if (!response.ok) { - let message = 'Failed to import Komoot activity.'; - try { - const body = await response.json(); - message = body.error || message; - } catch (ignored) { - // Ignore parse errors and show the generic message. - } - throw new Error(message); + if (activitiesToImport.length === 0) { + showStatus('All listed Komoot activities are already imported.'); + return; } - const data = await response.json(); - showStatus(data.message || 'Komoot activity imported.'); + let importedCount = 0; + let failedCount = 0; + + for (const activity of activitiesToImport) { + activity.uiImportStatus = 'importing'; + activity.uiImportError = null; + renderActivities(currentActivities); + + try { + const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import', { + method: 'POST', + body: buildImportPayload(activity.id) + }); + + if (!response.ok) { + let message = 'Failed to import Komoot activity.'; + try { + const body = await response.json(); + message = body.error || message; + } catch (ignored) { + // Ignore parse errors and show the generic message. + } + throw new Error(message); + } + + const data = await response.json(); + activity.imported = data.status === 'IMPORTED' || data.status === 'SKIPPED_ALREADY_IMPORTED'; + activity.uiImportStatus = activity.imported ? 'imported' : null; + activity.uiImportError = null; + if (data.status === 'IMPORTED') { + importedCount += 1; + } + } catch (error) { + failedCount += 1; + activity.uiImportStatus = 'error'; + activity.uiImportError = error instanceof Error ? error.message : 'Failed to import Komoot activity.'; + } + + renderActivities(currentActivities); + } + + showStatus(`Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`); } catch (error) { - let message = error instanceof Error ? error.message : 'Failed to import Komoot activity.'; + let message = error instanceof Error ? error.message : 'Failed to import Komoot activities.'; if (error instanceof Error && error.message === 'Authentication failed') { return; @@ -350,6 +422,8 @@ setImportLoading(false); } }); + + updateImportButtonState(); }); diff --git a/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java new file mode 100644 index 0000000..e5296de --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java @@ -0,0 +1,95 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.model.entity.ActivitySummary; +import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.ActivitySummaryRepository; +import net.javahippie.fitpub.repository.AchievementRepository; +import net.javahippie.fitpub.repository.PersonalRecordRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ActivitySummaryServiceTest { + + private ActivitySummaryRepository activitySummaryRepository; + private ActivityRepository activityRepository; + private PersonalRecordRepository personalRecordRepository; + private AchievementRepository achievementRepository; + private ActivitySummaryService service; + + @BeforeEach + void setUp() { + activitySummaryRepository = mock(ActivitySummaryRepository.class); + activityRepository = mock(ActivityRepository.class); + personalRecordRepository = mock(PersonalRecordRepository.class); + achievementRepository = mock(AchievementRepository.class); + service = new ActivitySummaryService( + activitySummaryRepository, + activityRepository, + personalRecordRepository, + achievementRepository + ); + } + + @Test + @DisplayName("Should retry summary save as update when concurrent insert hits unique constraint") + void shouldRetrySummarySaveAfterConcurrentInsert() { + UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + LocalDate date = LocalDate.of(2025, 10, 8); + LocalDate weekStart = LocalDate.of(2025, 10, 6); + LocalDate weekEnd = LocalDate.of(2025, 10, 12); + + ActivitySummary existingSummary = ActivitySummary.builder() + .id(UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) + .userId(userId) + .periodType(ActivitySummary.PeriodType.WEEK) + .periodStart(weekStart) + .periodEnd(weekEnd) + .build(); + + when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart( + userId, ActivitySummary.PeriodType.WEEK, weekStart + )).thenReturn(Optional.empty(), Optional.of(existingSummary)); + + when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc( + eq(userId), + eq(weekStart.atStartOfDay()), + eq(weekEnd.plusDays(1).atStartOfDay()) + )).thenReturn(List.of( + Activity.builder() + .userId(userId) + .activityType(Activity.ActivityType.RIDE) + .startedAt(LocalDateTime.of(2025, 10, 8, 9, 0)) + .totalDurationSeconds(3600L) + .build() + )); + + when(personalRecordRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); + when(achievementRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); + + when(activitySummaryRepository.save(any(ActivitySummary.class))) + .thenThrow(new DataIntegrityViolationException("duplicate")) + .thenReturn(existingSummary); + + service.updateWeeklySummary(userId, date); + + verify(activitySummaryRepository, times(2)) + .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart); + verify(activitySummaryRepository, times(2)).save(any(ActivitySummary.class)); + } +} diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index 35f9b18..aeb035a 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -1,5 +1,6 @@ package net.javahippie.fitpub.service; +import net.javahippie.fitpub.model.dto.KomootActivityImportRequest; import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse; import net.javahippie.fitpub.model.dto.KomootImportRequest; @@ -20,6 +21,7 @@ import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.util.Base64; import java.util.List; +import java.util.Optional; import java.util.TimeZone; import java.util.UUID; @@ -210,35 +212,14 @@ class KomootImportServiceTest { } @Test - @DisplayName("Should import newest not-yet-imported Komoot activity via GPX and override metadata") - void shouldImportNewestNotYetImportedActivity() { + @DisplayName("Should import a specific Komoot activity via GPX and override metadata") + void shouldImportSpecificKomootActivity() { String authHeader = "Basic " + Base64.getEncoder() .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); UUID importedActivityId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); - when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); - - 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")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "_embedded": { - "tours": [ - { - "id": 2880957035, - "name": "Latest Ride", - "sport": "mtb_easy", - "status": "public", - "type": "tour_recorded", - "date": "2026-04-27T18:15:00+02:00" - } - ] - }, - "_links": {} - } - """, MediaType.APPLICATION_JSON)); + when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty()); server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035?hl=en")) .andExpect(method(HttpMethod.GET)) @@ -276,13 +257,14 @@ class KomootImportServiceTest { when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest("user@example.com", "secret", "123456", null, null), + KomootImportExecutionResponse response = service.importActivity( + new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957035L), userId ); assertThat(response.importedActivityId()).isEqualTo(importedActivityId); assertThat(response.importedKomootActivityId()).isEqualTo(2880957035L); + assertThat(response.status()).isEqualTo("IMPORTED"); assertThat(importedActivity.getKomootActivityId()).isEqualTo(2880957035L); assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride"); assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot"); @@ -294,88 +276,23 @@ class KomootImportServiceTest { } @Test - @DisplayName("Should respect date range when choosing Komoot import candidate") - void shouldRespectDateRangeWhenImportingFirstNewActivity() { - String authHeader = "Basic " + Base64.getEncoder() - .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); + @DisplayName("Should skip already imported Komoot activity") + void shouldSkipAlreadyImportedKomootActivity() { UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - UUID importedActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); + UUID existingActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); - when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); + when(activityRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn( + Optional.of(Activity.builder().id(existingActivityId).userId(userId).komootActivityId(3002L).build()) + ); - 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-26T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "_embedded": { - "tours": [ - { - "id": 3002, - "name": "Inside Range Candidate", - "sport": "mtb_easy", - "status": "public", - "type": "tour_recorded", - "date": "2026-04-27T18:15:00+02:00" - } - ] - }, - "_links": {} - } - """, MediaType.APPLICATION_JSON)); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002?hl=en")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "id": "3002", - "name": "Inside Range Candidate", - "description": "Imported from Komoot", - "status": "public", - "sport": "mtb_easy" - } - """, MediaType.APPLICATION_JSON)); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002.gpx")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - - - Inside Range Candidate - - """, MediaType.APPLICATION_XML)); - - Activity importedActivity = Activity.builder() - .id(importedActivityId) - .userId(userId) - .activityType(Activity.ActivityType.OTHER) - .title("GPX Title") - .description(null) - .visibility(Activity.Visibility.PRIVATE) - .sourceFileFormat("GPX") - .build(); - - when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); - when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest( - "user@example.com", - "secret", - "123456", - LocalDate.of(2026, 4, 27), - LocalDate.of(2026, 4, 27) - ), + KomootImportExecutionResponse response = service.importActivity( + new KomootActivityImportRequest("user@example.com", "secret", "123456", 3002L), userId ); + assertThat(response.importedActivityId()).isNull(); assertThat(response.importedKomootActivityId()).isEqualTo(3002L); - assertThat(importedActivity.getKomootActivityId()).isEqualTo(3002L); - - verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); - server.verify(); + assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED"); } @Test @@ -386,28 +303,7 @@ class KomootImportServiceTest { UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); UUID importedActivityId = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"); - when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); - - 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")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "_embedded": { - "tours": [ - { - "id": 2880957036, - "name": "Unknown Sport", - "sport": "space_biking", - "status": "private", - "type": "tour_recorded", - "date": "2026-04-27T18:15:00+02:00" - } - ] - }, - "_links": {} - } - """, MediaType.APPLICATION_JSON)); + when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty()); server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036?hl=en")) .andExpect(method(HttpMethod.GET)) @@ -445,12 +341,13 @@ class KomootImportServiceTest { when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest("user@example.com", "secret", "123456", null, null), + KomootImportExecutionResponse response = service.importActivity( + new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957036L), userId ); assertThat(response.importedActivityId()).isEqualTo(importedActivityId); + assertThat(response.status()).isEqualTo("IMPORTED"); assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER); verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass From f7f919f0b10727f361626d5e02ee130375a44cd8 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 15:24:15 +0200 Subject: [PATCH 07/23] feat(komoot): improve import flow with throttling, cancellation, and UI guidance Signed-off-by: Marcus Fihlon --- .../fitpub/service/KomootImportService.java | 40 +++++++++++ src/main/resources/application.yml | 3 + .../templates/activities/komoot.html | 66 +++++++++++++++++-- .../service/KomootImportServiceTest.java | 24 ++++++- 4 files changed, 124 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index 26bae1c..6694d30 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -61,6 +61,15 @@ public class KomootImportService { @Value("${fitpub.komoot.base-url:https://www.komoot.com}") private String komootBaseUrl; + @Value("${fitpub.komoot.paginated-request-delay-ms:1000}") + private long paginatedRequestDelayMillis; + + @Value("${fitpub.komoot.detail-to-gpx-delay-ms:500}") + private long detailToGpxDelayMillis; + + @Value("${fitpub.komoot.activity-import-delay-ms:3000}") + private long activityImportDelayMillis; + public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request, UUID fitPubUserId) { List activities = new ArrayList<>(); Set importedKomootActivityIds = new HashSet<>( @@ -80,6 +89,9 @@ public class KomootImportService { } extractActivities(root, activities, importedKomootActivityIds); nextUri = extractNextUri(root); + if (nextUri != null) { + pauseBeforeNextPageRequest(); + } } } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { throw new IllegalArgumentException("Komoot login failed. Check email, password and Komoot ID.", e); @@ -95,6 +107,10 @@ public class KomootImportService { return new KomootActivitiesResponse(request.userId(), activities.size(), activities); } + void pauseBeforeNextPageRequest() { + pause(paginatedRequestDelayMillis, "Interrupted while throttling paginated Komoot requests."); + } + public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) { if (activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).isPresent()) { return new KomootImportExecutionResponse( @@ -106,6 +122,7 @@ public class KomootImportService { } JsonNode details = fetchActivityDetails(request.email(), request.password(), request.activityId()); + pauseBetweenDetailAndGpxRequest(); byte[] gpxData = fetchActivityGpx(request.email(), request.password(), request.activityId()); ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile( @@ -145,6 +162,8 @@ public class KomootImportService { importedActivity.getActivityType() ); + pauseAfterActivityImport(); + return new KomootImportExecutionResponse( importedActivity.getId(), request.activityId(), @@ -153,6 +172,14 @@ public class KomootImportService { ); } + void pauseBetweenDetailAndGpxRequest() { + pause(detailToGpxDelayMillis, "Interrupted while throttling Komoot detail and GPX requests."); + } + + void pauseAfterActivityImport() { + pause(activityImportDelayMillis, "Interrupted while throttling Komoot activity imports."); + } + 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/") @@ -410,4 +437,17 @@ public class KomootImportService { JsonNode value = node.get(field); return value == null || value.isNull() ? null : value.asInt(); } + + private void pause(long delayMillis, String interruptedMessage) { + if (delayMillis <= 0) { + return; + } + + try { + Thread.sleep(delayMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interruptedMessage, e); + } + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a6b7f92..572481a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -106,6 +106,9 @@ fitpub: komoot: base-url: ${KOMOOT_BASE_URL:https://www.komoot.com} + paginated-request-delay-ms: ${KOMOOT_PAGINATED_REQUEST_DELAY_MS:1000} + detail-to-gpx-delay-ms: ${KOMOOT_DETAIL_TO_GPX_DELAY_MS:500} + activity-import-delay-ms: ${KOMOOT_ACTIVITY_IMPORT_DELAY_MS:3000} # Logging configuration logging: diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index 595462c..9fc1e91 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -19,15 +19,20 @@
    Important
    -
    +
    Your Komoot credentials are only used for this request and are not stored in FitPub.
    - Komoot does not provide a public API for this flow. This import currently depends on the - same web API endpoints used by the Komoot website and may stop working if Komoot changes them. + The import currently runs in this browser tab. If you leave or reload the page, remaining activities will not continue importing automatically.
    +
      +
    • Import starts with the oldest new activities, so progress begins at the bottom of the list.
    • +
    • FitPub adds short delays between Komoot requests during loading and import to reduce rate limiting.
    • +
    • This integration depends on Komoot web endpoints and may stop working if Komoot changes them.
    • +
    +
    @@ -81,6 +86,9 @@ Importing... +
    @@ -130,10 +138,13 @@ const importFirstBtn = document.getElementById('importFirstBtn'); const importFirstText = document.getElementById('importFirstText'); const importFirstSpinner = document.getElementById('importFirstSpinner'); + const cancelImportBtn = document.getElementById('cancelImportBtn'); let currentActivities = []; + let importCancellationRequested = false; + let importInProgress = false; function updateImportButtonState() { - importFirstBtn.disabled = currentActivities.length === 0; + importFirstBtn.disabled = importInProgress || currentActivities.length === 0; } function setLoading(loading) { @@ -143,9 +154,13 @@ } function setImportLoading(loading) { - importFirstBtn.disabled = loading; + importInProgress = loading; + loadActivitiesBtn.disabled = loading; + updateImportButtonState(); importFirstText.classList.toggle('d-none', loading); importFirstSpinner.classList.toggle('d-none', !loading); + cancelImportBtn.classList.toggle('d-none', !loading); + cancelImportBtn.disabled = !loading; } function showError(message) { @@ -222,6 +237,10 @@ } function renderImportStatus(activity) { + if (activity.uiImportStatus === 'queued') { + return ''; + } + if (activity.uiImportStatus === 'importing') { return ''; } @@ -289,6 +308,15 @@ return Boolean(payload.startDate) !== Boolean(payload.endDate); } + function resetQueuedActivities() { + for (const activity of currentActivities) { + if (activity.uiImportStatus === 'queued') { + activity.uiImportStatus = null; + activity.uiImportError = null; + } + } + } + form.addEventListener('submit', async function(event) { event.preventDefault(); clearError(); @@ -356,6 +384,7 @@ showError('Load Komoot activities before starting the import.'); return; } + importCancellationRequested = false; setImportLoading(true); try { @@ -368,8 +397,15 @@ return; } + for (const activity of activitiesToImport) { + activity.uiImportStatus = 'queued'; + activity.uiImportError = null; + } + renderActivities(currentActivities); + let importedCount = 0; let failedCount = 0; + let cancelled = false; for (const activity of activitiesToImport) { activity.uiImportStatus = 'importing'; @@ -407,9 +443,20 @@ } renderActivities(currentActivities); + + if (importCancellationRequested) { + cancelled = true; + resetQueuedActivities(); + renderActivities(currentActivities); + break; + } } - showStatus(`Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`); + if (cancelled) { + showStatus(`Import stopped after the current activity. Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`); + } else { + showStatus(`Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`); + } } catch (error) { let message = error instanceof Error ? error.message : 'Failed to import Komoot activities.'; @@ -420,9 +467,16 @@ showError(message); } finally { setImportLoading(false); + importCancellationRequested = false; } }); + cancelImportBtn.addEventListener('click', function() { + importCancellationRequested = true; + cancelImportBtn.disabled = true; + showStatus('Import will stop after the current activity finishes.'); + }); + updateImportButtonState(); }); diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index aeb035a..4bb13ef 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -28,7 +28,9 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.client.ExpectedCount.once; @@ -58,6 +60,9 @@ class KomootImportServiceTest { activityPostProcessingService = mock(ActivityPostProcessingService.class); service = new KomootImportService(restTemplate, activityRepository, activityFileService, activityPostProcessingService); ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com"); + ReflectionTestUtils.setField(service, "paginatedRequestDelayMillis", 0L); + ReflectionTestUtils.setField(service, "detailToGpxDelayMillis", 0L); + ReflectionTestUtils.setField(service, "activityImportDelayMillis", 0L); } @AfterEach @@ -70,6 +75,8 @@ class KomootImportServiceTest { String authHeader = "Basic " + Base64.getEncoder() .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + KomootImportService throttledService = spy(service); + doNothing().when(throttledService).pauseBeforeNextPageRequest(); when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L)); @@ -127,7 +134,7 @@ class KomootImportServiceTest { } """, MediaType.APPLICATION_JSON)); - KomootActivitiesResponse response = service.fetchCompletedActivities( + KomootActivitiesResponse response = throttledService.fetchCompletedActivities( new KomootImportRequest("user@example.com", "secret", "123456", null, null), userId); @@ -139,6 +146,7 @@ class KomootImportServiceTest { assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk"); assertThat(response.activities().get(1).imported()).isTrue(); + verify(throttledService).pauseBeforeNextPageRequest(); server.verify(); } @@ -218,6 +226,9 @@ class KomootImportServiceTest { .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); UUID importedActivityId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + KomootImportService throttledService = spy(service); + doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest(); + doNothing().when(throttledService).pauseAfterActivityImport(); when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty()); @@ -257,7 +268,7 @@ class KomootImportServiceTest { when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - KomootImportExecutionResponse response = service.importActivity( + KomootImportExecutionResponse response = throttledService.importActivity( new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957035L), userId ); @@ -271,6 +282,8 @@ class KomootImportServiceTest { assertThat(importedActivity.getVisibility()).isEqualTo(Activity.Visibility.PUBLIC); assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.RIDE); + verify(throttledService).pauseBetweenDetailAndGpxRequest(); + verify(throttledService).pauseAfterActivityImport(); verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); server.verify(); } @@ -302,6 +315,9 @@ class KomootImportServiceTest { .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); UUID importedActivityId = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"); + KomootImportService throttledService = spy(service); + doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest(); + doNothing().when(throttledService).pauseAfterActivityImport(); when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty()); @@ -341,7 +357,7 @@ class KomootImportServiceTest { when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - KomootImportExecutionResponse response = service.importActivity( + KomootImportExecutionResponse response = throttledService.importActivity( new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957036L), userId ); @@ -350,6 +366,8 @@ class KomootImportServiceTest { assertThat(response.status()).isEqualTo("IMPORTED"); assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER); + verify(throttledService).pauseBetweenDetailAndGpxRequest(); + verify(throttledService).pauseAfterActivityImport(); verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); server.verify(); } From 5945a2b1391714c97fcdf7f37d62f3e0a3776e10 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 15:44:06 +0200 Subject: [PATCH 08/23] feat(komoot): link imported activities to their FitPub detail pages Signed-off-by: Marcus Fihlon --- .../model/dto/KomootActivitySummaryDTO.java | 4 ++- .../fitpub/repository/ActivityRepository.java | 16 +++++++++++ .../fitpub/service/KomootImportService.java | 27 +++++++++++++++---- .../templates/activities/komoot.html | 11 +++++++- .../service/KomootImportServiceTest.java | 25 ++++++++++++++++- 5 files changed, 75 insertions(+), 8 deletions(-) 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 d551b49..a75ae5b 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java @@ -1,6 +1,7 @@ package net.javahippie.fitpub.model.dto; import java.time.OffsetDateTime; +import java.util.UUID; /** * Reduced activity representation returned by the Komoot import preview. @@ -17,6 +18,7 @@ public record KomootActivitySummaryDTO( Integer durationSeconds, Integer timeInMotionSeconds, Double elevationUp, - boolean imported + boolean imported, + UUID fitPubActivityId ) { } diff --git a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java index d44dc1f..7a8c03d 100644 --- a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java @@ -20,6 +20,11 @@ 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(); @@ -35,6 +40,17 @@ public interface ActivityRepository extends JpaRepository { */ 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/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index 6694d30..b57f1c4 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -33,8 +33,10 @@ import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Base64; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; @@ -74,6 +76,14 @@ public class KomootImportService { List activities = new ArrayList<>(); Set importedKomootActivityIds = new HashSet<>( activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); + Map fitPubActivityIdsByKomootId = new HashMap<>(); + if (!importedKomootActivityIds.isEmpty()) { + activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn( + fitPubUserId, + new ArrayList<>(importedKomootActivityIds) + ) + .forEach(link -> fitPubActivityIdsByKomootId.put(link.getKomootActivityId(), link.getId())); + } URI nextUri = buildInitialUri(request); HttpEntity httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); @@ -87,7 +97,7 @@ public class KomootImportService { if (root == null) { throw new IllegalStateException("Komoot returned an empty response body."); } - extractActivities(root, activities, importedKomootActivityIds); + extractActivities(root, activities, importedKomootActivityIds, fitPubActivityIdsByKomootId); nextUri = extractNextUri(root); if (nextUri != null) { pauseBeforeNextPageRequest(); @@ -112,9 +122,10 @@ public class KomootImportService { } public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) { - if (activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).isPresent()) { + Activity existingActivity = activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).orElse(null); + if (existingActivity != null) { return new KomootImportExecutionResponse( - null, + existingActivity.getId(), request.activityId(), "SKIPPED_ALREADY_IMPORTED", "Komoot activity " + request.activityId() + " was already imported." @@ -248,7 +259,12 @@ public class KomootImportService { return "Basic " + encoded; } - private void extractActivities(JsonNode root, List activities, Set importedKomootActivityIds) { + private void extractActivities( + JsonNode root, + List activities, + Set importedKomootActivityIds, + Map fitPubActivityIdsByKomootId + ) { JsonNode tours = root.path("_embedded").path("tours"); if (!tours.isArray()) { return; @@ -268,7 +284,8 @@ public class KomootImportService { nullableInteger(tour, "duration"), nullableInteger(tour, "time_in_motion"), nullableDouble(tour, "elevation_up"), - importedKomootActivityIds.contains(activityId) + importedKomootActivityIds.contains(activityId), + fitPubActivityIdsByKomootId.get(activityId) )); } } diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index 9fc1e91..07f61c0 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -236,6 +236,14 @@ return `${escapeHtml(activityType)}`; } + function renderActivityTitle(activity) { + const title = escapeHtml(activity.name || 'Untitled activity'); + if (activity.fitPubActivityId) { + return `${title}`; + } + return `
    ${title}
    `; + } + function renderImportStatus(activity) { if (activity.uiImportStatus === 'queued') { return ''; @@ -268,7 +276,7 @@ resultsBody.innerHTML = activities.map(activity => ` -
    ${escapeHtml(activity.name || 'Untitled activity')}
    + ${renderActivityTitle(activity)} ${formatDate(activity.date)} ${formatActivityTypeBadge(activity.mappedActivityType)} ${formatDistance(activity.distanceMeters)} @@ -431,6 +439,7 @@ const data = await response.json(); activity.imported = data.status === 'IMPORTED' || data.status === 'SKIPPED_ALREADY_IMPORTED'; + activity.fitPubActivityId = data.importedActivityId || activity.fitPubActivityId; activity.uiImportStatus = activity.imported ? 'imported' : null; activity.uiImportError = null; if (data.status === 'IMPORTED') { diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index 4bb13ef..319ad83 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -41,6 +41,20 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat class KomootImportServiceTest { + private static ActivityRepository.KomootImportLinkProjection importLink(UUID activityId, Long komootActivityId) { + return new ActivityRepository.KomootImportLinkProjection() { + @Override + public UUID getId() { + return activityId; + } + + @Override + public Long getKomootActivityId() { + return komootActivityId; + } + }; + } + private RestTemplate restTemplate; private MockRestServiceServer server; private KomootImportService service; @@ -77,8 +91,11 @@ class KomootImportServiceTest { UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); KomootImportService throttledService = spy(service); 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))) + .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")) .andExpect(method(HttpMethod.GET)) @@ -142,9 +159,11 @@ class KomootImportServiceTest { 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); verify(throttledService).pauseBeforeNextPageRequest(); server.verify(); @@ -156,8 +175,11 @@ class KomootImportServiceTest { String authHeader = "Basic " + Base64.getEncoder() .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); 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))) + .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")) .andExpect(method(HttpMethod.GET)) @@ -202,6 +224,7 @@ class KomootImportServiceTest { 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); server.verify(); } @@ -303,7 +326,7 @@ class KomootImportServiceTest { userId ); - assertThat(response.importedActivityId()).isNull(); + assertThat(response.importedActivityId()).isEqualTo(existingActivityId); assertThat(response.importedKomootActivityId()).isEqualTo(3002L); assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED"); } From b5e88a317f97c1aa76a1af244ef15fd775f344ee Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 15:52:26 +0200 Subject: [PATCH 09/23] revert: remove unrelated change from feature branch This reverts a change that does not belong to this feature branch. The change will be moved to a separate branch and submitted as its own pull request. Signed-off-by: Marcus Fihlon --- .../service/ActivitySummaryService.java | 66 ++++++++----------- 1 file changed, 28 insertions(+), 38 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java index ead4891..357bc8d 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java @@ -8,7 +8,6 @@ import net.javahippie.fitpub.repository.ActivityRepository; import net.javahippie.fitpub.repository.ActivitySummaryRepository; import net.javahippie.fitpub.repository.AchievementRepository; import net.javahippie.fitpub.repository.PersonalRecordRepository; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -68,7 +67,17 @@ public class ActivitySummaryService { LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)); LocalDate weekEnd = weekStart.plusDays(6); - saveSummaryWithRetry(userId, ActivitySummary.PeriodType.WEEK, weekStart, weekEnd); + ActivitySummary summary = activitySummaryRepository + .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart) + .orElse(ActivitySummary.builder() + .userId(userId) + .periodType(ActivitySummary.PeriodType.WEEK) + .periodStart(weekStart) + .periodEnd(weekEnd) + .build()); + + calculateAndUpdateSummary(summary, userId, weekStart.atStartOfDay(), weekEnd.plusDays(1).atStartOfDay()); + activitySummaryRepository.save(summary); } /** @@ -79,7 +88,17 @@ public class ActivitySummaryService { LocalDate monthStart = date.with(TemporalAdjusters.firstDayOfMonth()); LocalDate monthEnd = date.with(TemporalAdjusters.lastDayOfMonth()); - saveSummaryWithRetry(userId, ActivitySummary.PeriodType.MONTH, monthStart, monthEnd); + ActivitySummary summary = activitySummaryRepository + .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.MONTH, monthStart) + .orElse(ActivitySummary.builder() + .userId(userId) + .periodType(ActivitySummary.PeriodType.MONTH) + .periodStart(monthStart) + .periodEnd(monthEnd) + .build()); + + calculateAndUpdateSummary(summary, userId, monthStart.atStartOfDay(), monthEnd.plusDays(1).atStartOfDay()); + activitySummaryRepository.save(summary); } /** @@ -90,46 +109,17 @@ public class ActivitySummaryService { LocalDate yearStart = date.with(TemporalAdjusters.firstDayOfYear()); LocalDate yearEnd = date.with(TemporalAdjusters.lastDayOfYear()); - saveSummaryWithRetry(userId, ActivitySummary.PeriodType.YEAR, yearStart, yearEnd); - } - - private void saveSummaryWithRetry( - UUID userId, - ActivitySummary.PeriodType periodType, - LocalDate periodStart, - LocalDate periodEnd - ) { ActivitySummary summary = activitySummaryRepository - .findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart) + .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.YEAR, yearStart) .orElse(ActivitySummary.builder() .userId(userId) - .periodType(periodType) - .periodStart(periodStart) - .periodEnd(periodEnd) + .periodType(ActivitySummary.PeriodType.YEAR) + .periodStart(yearStart) + .periodEnd(yearEnd) .build()); - LocalDateTime startDateTime = periodStart.atStartOfDay(); - LocalDateTime endDateTime = periodEnd.plusDays(1).atStartOfDay(); - - calculateAndUpdateSummary(summary, userId, startDateTime, endDateTime); - - try { - activitySummaryRepository.save(summary); - } catch (DataIntegrityViolationException e) { - log.debug( - "Summary already created concurrently for user {}, period {} starting {}. Retrying as update.", - userId, - periodType, - periodStart - ); - - ActivitySummary existingSummary = activitySummaryRepository - .findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart) - .orElseThrow(() -> e); - - calculateAndUpdateSummary(existingSummary, userId, startDateTime, endDateTime); - activitySummaryRepository.save(existingSummary); - } + calculateAndUpdateSummary(summary, userId, yearStart.atStartOfDay(), yearEnd.plusDays(1).atStartOfDay()); + activitySummaryRepository.save(summary); } /** From df3cd0616dddc7e4edb26e219b8e901c6e07eb61 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 16:09:17 +0200 Subject: [PATCH 10/23] revert: remove unrelated change from feature branch This reverts a change that does not belong to this feature branch. The change will be moved to a separate branch and submitted as its own pull request. Signed-off-by: Marcus Fihlon --- .../service/ActivitySummaryServiceTest.java | 95 ------------------- 1 file changed, 95 deletions(-) delete mode 100644 src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java diff --git a/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java deleted file mode 100644 index e5296de..0000000 --- a/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package net.javahippie.fitpub.service; - -import net.javahippie.fitpub.model.entity.Activity; -import net.javahippie.fitpub.model.entity.ActivitySummary; -import net.javahippie.fitpub.repository.ActivityRepository; -import net.javahippie.fitpub.repository.ActivitySummaryRepository; -import net.javahippie.fitpub.repository.AchievementRepository; -import net.javahippie.fitpub.repository.PersonalRecordRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.dao.DataIntegrityViolationException; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class ActivitySummaryServiceTest { - - private ActivitySummaryRepository activitySummaryRepository; - private ActivityRepository activityRepository; - private PersonalRecordRepository personalRecordRepository; - private AchievementRepository achievementRepository; - private ActivitySummaryService service; - - @BeforeEach - void setUp() { - activitySummaryRepository = mock(ActivitySummaryRepository.class); - activityRepository = mock(ActivityRepository.class); - personalRecordRepository = mock(PersonalRecordRepository.class); - achievementRepository = mock(AchievementRepository.class); - service = new ActivitySummaryService( - activitySummaryRepository, - activityRepository, - personalRecordRepository, - achievementRepository - ); - } - - @Test - @DisplayName("Should retry summary save as update when concurrent insert hits unique constraint") - void shouldRetrySummarySaveAfterConcurrentInsert() { - UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - LocalDate date = LocalDate.of(2025, 10, 8); - LocalDate weekStart = LocalDate.of(2025, 10, 6); - LocalDate weekEnd = LocalDate.of(2025, 10, 12); - - ActivitySummary existingSummary = ActivitySummary.builder() - .id(UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) - .userId(userId) - .periodType(ActivitySummary.PeriodType.WEEK) - .periodStart(weekStart) - .periodEnd(weekEnd) - .build(); - - when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart( - userId, ActivitySummary.PeriodType.WEEK, weekStart - )).thenReturn(Optional.empty(), Optional.of(existingSummary)); - - when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc( - eq(userId), - eq(weekStart.atStartOfDay()), - eq(weekEnd.plusDays(1).atStartOfDay()) - )).thenReturn(List.of( - Activity.builder() - .userId(userId) - .activityType(Activity.ActivityType.RIDE) - .startedAt(LocalDateTime.of(2025, 10, 8, 9, 0)) - .totalDurationSeconds(3600L) - .build() - )); - - when(personalRecordRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); - when(achievementRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); - - when(activitySummaryRepository.save(any(ActivitySummary.class))) - .thenThrow(new DataIntegrityViolationException("duplicate")) - .thenReturn(existingSummary); - - service.updateWeeklySummary(userId, date); - - verify(activitySummaryRepository, times(2)) - .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart); - verify(activitySummaryRepository, times(2)).save(any(ActivitySummary.class)); - } -} From 7a0315d85565416302a3c4794cbf3bff58d9ab1c Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 16:16:11 +0200 Subject: [PATCH 11/23] fix: linter warnings Signed-off-by: Marcus Fihlon --- .../net/javahippie/fitpub/service/KomootImportServiceTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index 319ad83..dd32fea 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -55,7 +55,6 @@ class KomootImportServiceTest { }; } - private RestTemplate restTemplate; private MockRestServiceServer server; private KomootImportService service; private ActivityRepository activityRepository; @@ -67,7 +66,7 @@ class KomootImportServiceTest { void setUp() { originalTimeZone = TimeZone.getDefault(); TimeZone.setDefault(TimeZone.getTimeZone("Europe/Zurich")); - restTemplate = new RestTemplate(); + RestTemplate restTemplate = new RestTemplate(); server = MockRestServiceServer.bindTo(restTemplate).build(); activityRepository = mock(ActivityRepository.class); activityFileService = mock(ActivityFileService.class); From 6bd7ab8748bb52a118a86f62cf7b40b2e3e12235 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 16:40:04 +0200 Subject: [PATCH 12/23] style(komoot): reuse shared loading indicator for activity list loading Signed-off-by: Marcus Fihlon --- .../templates/activities/komoot.html | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index 07f61c0..ec70fcc 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -69,13 +69,7 @@
    +
    +
    + Loading... +
    +

    Loading Komoot activities...

    +
    +

    @@ -129,12 +130,11 @@ document.addEventListener('DOMContentLoaded', function() { const form = document.getElementById('komootImportForm'); const errorAlert = document.getElementById('errorAlert'); + const loadingIndicator = document.getElementById('loadingIndicator'); const resultsSection = document.getElementById('resultsSection'); const resultsBody = document.getElementById('resultsBody'); const resultCount = document.getElementById('resultCount'); const loadActivitiesBtn = document.getElementById('loadActivitiesBtn'); - const loadActivitiesText = document.getElementById('loadActivitiesText'); - const loadActivitiesSpinner = document.getElementById('loadActivitiesSpinner'); const importFirstBtn = document.getElementById('importFirstBtn'); const importFirstText = document.getElementById('importFirstText'); const importFirstSpinner = document.getElementById('importFirstSpinner'); @@ -149,8 +149,7 @@ function setLoading(loading) { loadActivitiesBtn.disabled = loading; - loadActivitiesText.classList.toggle('d-none', loading); - loadActivitiesSpinner.classList.toggle('d-none', !loading); + loadingIndicator.classList.toggle('d-none', !loading); } function setImportLoading(loading) { From 84735594f2a889ca00bd746712aecadbc4a2b381 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 17:10:34 +0200 Subject: [PATCH 13/23] fix(komoot): send explicit GPX accept header for imports Signed-off-by: Marcus Fihlon --- .../fitpub/service/KomootImportService.java | 13 ++++++++----- .../fitpub/service/KomootImportServiceTest.java | 2 ++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index b57f1c4..7f92073 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -320,14 +320,17 @@ public class KomootImportService { for (URI candidateUri : candidateUris) { try { - ResponseEntity response = restTemplate.exchange( + byte[] body = restTemplate.execute( candidateUri, HttpMethod.GET, - httpEntity, - byte[].class + request -> request.getHeaders().putAll(httpEntity.getHeaders()), + response -> { + if (response.getBody() == null) { + return null; + } + return response.getBody().readAllBytes(); + } ); - - byte[] body = response.getBody(); if (body == null || body.length == 0) { throw new IllegalStateException("Komoot returned an empty GPX response."); } diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index dd32fea..37d43d7 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -270,6 +270,7 @@ class KomootImportServiceTest { server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035.gpx")) .andExpect(method(HttpMethod.GET)) .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml")) .andRespond(withSuccess(""" @@ -359,6 +360,7 @@ class KomootImportServiceTest { server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036.gpx")) .andExpect(method(HttpMethod.GET)) .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml")) .andRespond(withSuccess(""" From ea47cccdb158f3b598168132a523cc0917537b5e Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 11:01:21 +0200 Subject: [PATCH 14/23] 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 15/23] 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(); From 67999e8a4a3d2da362e388b487caa134b5711c85 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 11:34:37 +0200 Subject: [PATCH 16/23] refactor(komoot): use explicit FitPub import user agent Signed-off-by: Marcus Fihlon --- .../net/javahippie/fitpub/service/KomootImportService.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index f87c607..4030f95 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -243,9 +243,7 @@ public class KomootImportService { headers.setAccept(List.of(MediaType.parseMediaType("application/hal+json"), MediaType.APPLICATION_JSON)); headers.setContentType(MediaType.APPLICATION_JSON); headers.setAcceptLanguageAsLocales(List.of(java.util.Locale.ENGLISH)); - headers.set(HttpHeaders.USER_AGENT, - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " - + "(KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"); + headers.set(HttpHeaders.USER_AGENT, "FitPub Komoot Import"); headers.set(HttpHeaders.AUTHORIZATION, basicAuth(email, password)); return headers; } From 70e7632699d52df140763e157dec888a1e28b196 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 12:35:20 +0200 Subject: [PATCH 17/23] feat(komoot): show visibility icons in activity list Signed-off-by: Marcus Fihlon --- .../resources/templates/activities/komoot.html | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index ec70fcc..2d99adc 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -108,6 +108,7 @@ + @@ -264,17 +265,32 @@ return ''; } + function renderVisibilityIcon(activity) { + const status = String(activity.status || '').toLowerCase(); + + if (status === 'public') { + return ''; + } + + if (status === 'friends' || status === 'followers' || status === 'close_friends') { + return ''; + } + + return ''; + } + function renderActivities(activities) { resultCount.textContent = activities.length; if (activities.length === 0) { - resultsBody.innerHTML = ''; + resultsBody.innerHTML = ''; resultsSection.classList.remove('d-none'); return; } resultsBody.innerHTML = activities.map(activity => ` + From 8a900ccd30398872719a9d7a3b591142d335f744 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 14:14:58 +0200 Subject: [PATCH 18/23] feat(komoot): prefill import date range with current year Signed-off-by: Marcus Fihlon --- .../fitpub/controller/KomootImportViewController.java | 5 +++++ src/main/resources/templates/activities/komoot.html | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java index d3ad006..8ac7e7d 100644 --- a/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java @@ -4,6 +4,8 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import java.time.LocalDate; + /** * Serves the Komoot import preview page. */ @@ -12,7 +14,10 @@ public class KomootImportViewController { @GetMapping("/komoot-import") public String komootImportPage(Model model) { + LocalDate today = LocalDate.now(); model.addAttribute("pageTitle", "Komoot Import"); + model.addAttribute("defaultStartDate", today.withDayOfYear(1)); + model.addAttribute("defaultEndDate", today); return "activities/komoot"; } } diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index 2d99adc..6d4ca56 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -56,11 +56,11 @@
    - +
    - +
    Both dates must be set together. Inclusive, day-based filter.
    From 9a9785973df1c17094e965618418b4ee142c7fd1 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 15:10:24 +0200 Subject: [PATCH 19/23] feat(komoot): show import progress count in button label Signed-off-by: Marcus Fihlon --- .../resources/templates/activities/komoot.html | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index 6d4ca56..b8a69fd 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -77,7 +77,7 @@ - Importing... + Importing...
    Name Date Type
    No completed activities found.
    No completed activities found.
    ${renderVisibilityIcon(activity)} ${renderActivityTitle(activity)} ${formatDate(activity.date)} ${formatActivityTypeBadge(activity.mappedActivityType)}