feat(komoot): add completed activities preview import flow

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
Marcus Fihlon 2026-04-28 12:18:02 +02:00
parent 9e529f8b99
commit 7ca09f0f27
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
11 changed files with 671 additions and 0 deletions

View file

@ -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()

View file

@ -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<KomootActivitiesResponse> 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<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> 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<ErrorResponse> handleIllegalState(IllegalStateException e) {
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse(e.getMessage()));
}
record ErrorResponse(String error) {}
}

View file

@ -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";
}
}

View file

@ -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<KomootActivitySummaryDTO> activities
) {
}

View file

@ -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
) {
}

View file

@ -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.
*
* <p>The password is only used for the current request and is never persisted.</p>
*/
public record KomootImportRequest(
@NotBlank
@Email
String email,
@NotBlank
String password,
@NotBlank
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
String userId
) {
}

View file

@ -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.
*
* <p>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.</p>
*/
@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<KomootActivitySummaryDTO> activities = new ArrayList<>();
URI nextUri = buildInitialUri(request.userId());
HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password()));
try {
while (nextUri != null) {
ResponseEntity<JsonNode> 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<KomootActivitySummaryDTO> 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();
}
}

View file

@ -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:

View file

@ -0,0 +1,246 @@
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>Komoot Import</title>
</head>
<body>
<div layout:fragment="content">
<div class="row justify-content-center">
<div class="col-lg-10">
<h2 class="mb-4">
<i class="bi bi-signpost-split text-success"></i>
Komoot Import
</h2>
<div class="alert alert-secondary">
<div class="fw-semibold mb-1">Phase 1: Preview only</div>
<div class="small mb-2">
Your Komoot credentials are only used for this request and are not stored in FitPub.
</div>
<div class="small mb-0">
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.
</div>
</div>
<div id="errorAlert" class="alert alert-danger d-none" role="alert"></div>
<div class="card shadow-sm">
<div class="card-body p-4">
<form id="komootImportForm">
<div class="row g-3">
<div class="col-md-6">
<label for="email" class="form-label">Komoot Email</label>
<input type="email" class="form-control" id="email" name="email" required autocomplete="username">
</div>
<div class="col-md-6">
<label for="password" class="form-label">Komoot Password</label>
<input type="password" class="form-control" id="password" name="password" required autocomplete="current-password">
</div>
<div class="col-md-6">
<label for="userId" class="form-label">Komoot ID</label>
<input type="text" class="form-control" id="userId" name="userId" required inputmode="numeric" pattern="[0-9]+">
<div class="form-text">You can find the Komoot ID in your Komoot account settings.</div>
</div>
</div>
<div class="mt-4 d-flex justify-content-end">
<button type="submit" class="btn btn-primary" id="loadActivitiesBtn">
<span id="loadActivitiesText">
<i class="bi bi-arrow-repeat"></i> Load Completed Activities
</span>
<span id="loadActivitiesSpinner" class="d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Loading...
</span>
</button>
</div>
</form>
</div>
</div>
<div id="resultsSection" class="mt-4 d-none">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">
<i class="bi bi-list-check"></i>
Completed Activities
</h4>
<span class="badge text-bg-secondary" id="resultCount">0</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Name</th>
<th>Date</th>
<th>Sport</th>
<th>Distance</th>
<th>Duration</th>
<th>Elevation</th>
</tr>
</thead>
<tbody id="resultsBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('komootImportForm');
const errorAlert = document.getElementById('errorAlert');
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');
function setLoading(loading) {
loadActivitiesBtn.disabled = loading;
loadActivitiesText.classList.toggle('d-none', loading);
loadActivitiesSpinner.classList.toggle('d-none', !loading);
}
function showError(message) {
errorAlert.textContent = message;
errorAlert.classList.remove('d-none');
}
function clearError() {
errorAlert.textContent = '';
errorAlert.classList.add('d-none');
}
function formatDistance(meters) {
if (meters == null) {
return '-';
}
return (meters / 1000).toFixed(1) + ' km';
}
function formatDuration(seconds) {
if (seconds == null) {
return '-';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
return [hours, minutes, remainingSeconds]
.map((value, index) => index === 0 ? String(value) : String(value).padStart(2, '0'))
.join(':');
}
function formatElevation(elevationUp) {
if (elevationUp == null) {
return '-';
}
return Math.round(elevationUp) + ' m';
}
function formatDate(value) {
if (!value) {
return '-';
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function renderActivities(activities) {
resultCount.textContent = activities.length;
if (activities.length === 0) {
resultsBody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4">No completed activities found.</td></tr>';
resultsSection.classList.remove('d-none');
return;
}
resultsBody.innerHTML = activities.map(activity => `
<tr>
<td>
<div class="fw-semibold">${escapeHtml(activity.name || 'Untitled activity')}</div>
<div class="text-muted small">${escapeHtml(activity.type || '-')}</div>
</td>
<td>${formatDate(activity.date)}</td>
<td>${escapeHtml(activity.sport || '-')}</td>
<td>${formatDistance(activity.distanceMeters)}</td>
<td>${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)}</td>
<td>${formatElevation(activity.elevationUp)}</td>
</tr>
`).join('');
resultsSection.classList.remove('d-none');
}
form.addEventListener('submit', async function(event) {
event.preventDefault();
clearError();
resultsSection.classList.add('d-none');
const formData = new FormData(form);
const payload = {
email: formData.get('email'),
password: formData.get('password'),
userId: formData.get('userId')
};
setLoading(true);
try {
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', {
method: 'POST',
body: payload
});
if (!response.ok) {
let message = 'Failed to load Komoot activities.';
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();
renderActivities(data.activities || []);
form.querySelector('#password').value = '';
} catch (error) {
let message = error instanceof Error ? error.message : 'Failed to load Komoot activities.';
if (error instanceof Error && error.message === 'Authentication failed') {
return;
}
showError(message);
} finally {
setLoading(false);
}
});
});
</script>
</th:block>
</body>
</html>

View file

@ -97,6 +97,11 @@
<i class="bi bi-file-earmark-zip"></i> Batch Import
</a>
</li>
<li>
<a class="dropdown-item" th:href="@{/komoot-import}">
<i class="bi bi-signpost-split"></i> Komoot Import
</a>
</li>
</ul>
</li>

View file

@ -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();
}
}