Remove migration logic again

This commit is contained in:
Tim Zöller 2026-01-11 12:21:24 +01:00
parent 1a068e3217
commit 87da2a3861
6 changed files with 23 additions and 277 deletions

View file

@ -123,56 +123,6 @@ WHERE a.user_id = :userId
---
## Retroactive Migration
### For Activities Uploaded BEFORE This Feature
**Run once** to populate indoor flags for existing data:
```bash
POST /api/admin/migrate-indoor-flags
Authorization: Bearer <your-jwt-token>
```
This endpoint:
1. Fetches all FIT activities with stored raw files
2. Re-parses FIT files to extract SubSport
3. Updates `indoor`, `sub_sport`, and `indoor_detection_method`
4. Only saves if values changed (idempotent)
**Response**:
```json
{
"message": "Indoor activity flag migration complete",
"activitiesUpdated": 15
}
```
### How to Run Migration
#### Option 1: Using Browser DevTools
1. Login to FitPub
2. Open DevTools (F12) → Application → Local Storage
3. Copy `jwt_token` value
4. Use browser fetch or Postman:
```javascript
fetch('http://localhost:8080/api/admin/migrate-indoor-flags', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_TOKEN_HERE'
}
}).then(r => r.json()).then(console.log);
```
#### Option 2: Using curl
```bash
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
http://localhost:8080/api/admin/migrate-indoor-flags
```
---
## Code Structure
### Key Files Modified
@ -192,15 +142,9 @@ curl -X POST \
4. **Service**:
- `ActivityFileService.java` - Save SubSport & detection method to database
- `IndoorActivityMigrationService.java` - Retroactive migration logic
5. **Repository**:
- `UserHeatmapGridRepository.java` - Exclude indoor activities from heatmap queries
- `ActivityRepository.java` - Added query method for migration
6. **Controller**:
- `AdminController.java` - Migration endpoint
- `SecurityConfig.java` - Added `/api/admin/**` route (authenticated)
---
@ -241,7 +185,6 @@ Expected:
- **New uploads**: SubSport extracted during normal parsing (no overhead)
- **Timeline loading**: Simple column read (instant)
- **Heatmap queries**: Added `AND indoor = FALSE` filter (uses index)
- **Migration**: One-time operation, only re-parses FIT files with raw data
---
@ -286,7 +229,7 @@ GROUP BY indoor_detection_method;
**Fully implemented** multi-format indoor detection
**Backward compatible** - existing activities default to outdoor
**Retroactive migration** endpoint for old data
**Automatic detection** on upload for new activities
**Heatmap exclusion** automatic via SQL filters
**Timeline display** includes all activities
**Works for FIT and GPX** files with different detection strategies

View file

@ -152,9 +152,6 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.POST, "/api/users/*/follow").authenticated() // Follow user
.requestMatchers(HttpMethod.DELETE, "/api/users/*/follow").authenticated() // Unfollow user
// Protected endpoints - Admin API (data migration, maintenance)
.requestMatchers("/api/admin/**").authenticated()
// All other requests require authentication
.anyRequest().authenticated()
)

View file

@ -1,45 +0,0 @@
package org.operaton.fitpub.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.service.IndoorActivityMigrationService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Admin endpoints for data migration and maintenance tasks.
*/
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
@Slf4j
public class AdminController {
private final IndoorActivityMigrationService indoorActivityMigrationService;
/**
* Retroactively detect and update indoor activity flags for existing activities.
* Re-parses all FIT files to detect indoor activities based on SubSport field.
*
* This is a one-time migration endpoint to update existing data.
*
* @return number of activities updated
*/
@PostMapping("/migrate-indoor-flags")
public ResponseEntity<Map<String, Object>> migrateIndoorFlags() {
log.info("Admin: Starting indoor activity flag migration");
int updatedCount = indoorActivityMigrationService.updateIndoorFlagsForExistingActivities();
log.info("Admin: Indoor activity flag migration complete - {} activities updated", updatedCount);
return ResponseEntity.ok(Map.of(
"message", "Indoor activity flag migration complete",
"activitiesUpdated", updatedCount
));
}
}

View file

@ -91,7 +91,8 @@ public class AuthController {
*/
@GetMapping("/registration-status")
public ResponseEntity<RegistrationStatusResponse> getRegistrationStatus() {
return ResponseEntity.ok(new RegistrationStatusResponse(registrationEnabled));
boolean passwordRequired = configuredRegistrationPassword != null && !configuredRegistrationPassword.trim().isEmpty();
return ResponseEntity.ok(new RegistrationStatusResponse(registrationEnabled, passwordRequired));
}
/**
@ -183,5 +184,5 @@ public class AuthController {
/**
* Registration status response DTO.
*/
record RegistrationStatusResponse(boolean enabled) {}
record RegistrationStatusResponse(boolean enabled, boolean passwordRequired) {}
}

View file

@ -1,161 +0,0 @@
package org.operaton.fitpub.service;
import com.garmin.fit.Decode;
import com.garmin.fit.MesgBroadcaster;
import com.garmin.fit.SessionMesg;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.repository.ActivityRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* Service for retroactively detecting and updating indoor activity flags.
* This is a data migration service to update existing activities in the database.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class IndoorActivityMigrationService {
private final ActivityRepository activityRepository;
/**
* Retroactively update indoor flags for all existing FIT activities.
* Re-parses stored FIT files to detect indoor activities based on SubSport field.
*
* @return number of activities updated
*/
@Transactional
public int updateIndoorFlagsForExistingActivities() {
log.info("Starting retroactive indoor activity detection for all FIT activities");
// Find all activities with FIT files
List<Activity> fitActivities = activityRepository.findBySourceFileFormatAndRawActivityFileNotNull("FIT");
log.info("Found {} FIT activities to analyze", fitActivities.size());
AtomicInteger updatedCount = new AtomicInteger(0);
AtomicInteger errorCount = new AtomicInteger(0);
fitActivities.forEach(activity -> {
try {
IndoorDetectionResult result = detectIndoorFromFitFile(activity.getRawActivityFile());
boolean changed = false;
if (result.isIndoor() != activity.getIndoor()) {
activity.setIndoor(result.isIndoor());
changed = true;
}
if (result.getSubSport() != null && !result.getSubSport().equals(activity.getSubSport())) {
activity.setSubSport(result.getSubSport());
changed = true;
}
if (result.getDetectionMethod() != null &&
!result.getDetectionMethod().name().equals(activity.getIndoorDetectionMethod())) {
activity.setIndoorDetectionMethod(result.getDetectionMethod().name());
changed = true;
}
if (changed) {
activityRepository.save(activity);
updatedCount.incrementAndGet();
log.info("Updated activity {} - indoor: {}, subSport: {}, method: {}",
activity.getId(), result.isIndoor(), result.getSubSport(),
result.getDetectionMethod());
}
} catch (Exception e) {
errorCount.incrementAndGet();
log.warn("Failed to process activity {}: {}", activity.getId(), e.getMessage());
}
});
log.info("Retroactive indoor detection complete: {} activities updated, {} errors",
updatedCount.get(), errorCount.get());
return updatedCount.get();
}
/**
* Detect if a FIT file represents an indoor activity.
* Checks the SubSport field from the session message.
*
* @param fitFileBytes raw FIT file bytes
* @return detection result with indoor flag, SubSport, and detection method
*/
private IndoorDetectionResult detectIndoorFromFitFile(byte[] fitFileBytes) {
IndoorDetectionResult result = new IndoorDetectionResult();
result.setIndoor(false);
if (fitFileBytes == null || fitFileBytes.length == 0) {
return result;
}
AtomicBoolean isIndoor = new AtomicBoolean(false);
AtomicReference<String> subSport = new AtomicReference<>(null);
AtomicReference<Activity.IndoorDetectionMethod> method = new AtomicReference<>(null);
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(fitFileBytes)) {
Decode decode = new Decode();
MesgBroadcaster broadcaster = new MesgBroadcaster(decode);
// Listen for session messages to extract SubSport
broadcaster.addListener((SessionMesg session) -> {
if (session.getSubSport() != null) {
String subSportStr = session.getSubSport().toString();
subSport.set(subSportStr);
String subSportUpper = subSportStr.toUpperCase();
boolean detected = subSportUpper.contains("INDOOR") ||
subSportUpper.contains("TREADMILL") ||
subSportUpper.contains("VIRTUAL") ||
subSportUpper.contains("TRAINER");
if (detected) {
isIndoor.set(true);
method.set(Activity.IndoorDetectionMethod.FIT_SUBSPORT);
log.debug("Detected indoor activity from SubSport: {}", subSportStr);
}
}
});
// Decode the FIT file
if (!decode.checkFileIntegrity(inputStream)) {
log.warn("FIT file integrity check failed");
return result;
}
// Reset stream and read
inputStream.reset();
decode.read(inputStream, broadcaster);
result.setIndoor(isIndoor.get());
result.setSubSport(subSport.get());
result.setDetectionMethod(method.get());
} catch (Exception e) {
log.warn("Failed to parse FIT file: {}", e.getMessage());
}
return result;
}
/**
* Result of indoor activity detection.
*/
@Data
private static class IndoorDetectionResult {
private boolean indoor;
private String subSport;
private Activity.IndoorDetectionMethod detectionMethod;
}
}

View file

@ -231,15 +231,26 @@
const registrationPasswordField = document.getElementById('registrationPasswordField');
const registrationPasswordInput = document.getElementById('registrationPassword');
// Check if registration password is required by checking URL parameters
// If ?invite=true or REGISTRATION_PASSWORD is set, show the field
const urlParams = new URLSearchParams(window.location.search);
const showRegistrationPassword = urlParams.has('invite') || urlParams.has('code');
// Fetch registration status from API
try {
const response = await fetch('/api/auth/registration-status');
const data = await response.json();
// Always show the field and let backend validate
// This simplifies the logic - if not required, backend will ignore it
registrationPasswordField.style.display = 'block';
registrationPasswordInput.required = true;
if (data.passwordRequired) {
// Show registration password field if required
registrationPasswordField.style.display = 'block';
registrationPasswordInput.required = true;
} else {
// Hide registration password field if not required
registrationPasswordField.style.display = 'none';
registrationPasswordInput.required = false;
}
} catch (error) {
console.error('Failed to fetch registration status:', error);
// On error, assume password is not required
registrationPasswordField.style.display = 'none';
registrationPasswordInput.required = false;
}
// Password confirmation validation
confirmPassword.addEventListener('input', function() {