Delayed Fediverse publication
This commit is contained in:
parent
47fd3808d2
commit
e203250104
16 changed files with 98 additions and 136 deletions
1
.idea/data_source_mapping.xml
generated
1
.idea/data_source_mapping.xml
generated
|
|
@ -1,7 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="DataSourcePerFileMappings">
|
<component name="DataSourcePerFileMappings">
|
||||||
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/2564811a-81f9-4d83-b1b1-04cb2763e3fa/console.sql" value="2564811a-81f9-4d83-b1b1-04cb2763e3fa" />
|
|
||||||
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/2564811a-81f9-4d83-b1b1-04cb2763e3fa/console_1.sql" value="2564811a-81f9-4d83-b1b1-04cb2763e3fa" />
|
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/2564811a-81f9-4d83-b1b1-04cb2763e3fa/console_1.sql" value="2564811a-81f9-4d83-b1b1-04cb2763e3fa" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
1
.idea/sqldialects.xml
generated
1
.idea/sqldialects.xml
generated
|
|
@ -3,5 +3,6 @@
|
||||||
<component name="SqlDialectMappings">
|
<component name="SqlDialectMappings">
|
||||||
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V23__add_gadm_table.sql" dialect="PostgreSQL" />
|
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V23__add_gadm_table.sql" dialect="PostgreSQL" />
|
||||||
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V24__add_location_to_activity.sql" dialect="PostgreSQL" />
|
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V24__add_location_to_activity.sql" dialect="PostgreSQL" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V26__add_published_to_activities.sql" dialect="H2" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -120,13 +120,8 @@ public class ActivityController {
|
||||||
// - Personal Records checking
|
// - Personal Records checking
|
||||||
// - Weather data fetching
|
// - Weather data fetching
|
||||||
// - Heatmap grid updates
|
// - Heatmap grid updates
|
||||||
// - Federation push (includes image generation)
|
|
||||||
//
|
//
|
||||||
// Operations run in separate transactions with proper ordering:
|
// Federation is deferred until the user finalizes via PUT (metadata update)
|
||||||
// - Personal Records and Heatmap run in parallel
|
|
||||||
// - Weather → Federation run sequentially (weather must complete before federation)
|
|
||||||
//
|
|
||||||
// Activity is immediately visible to user, processing continues in background
|
|
||||||
activityPostProcessingService.processActivityAsync(activity.getId(), user.getId());
|
activityPostProcessingService.processActivityAsync(activity.getId(), user.getId());
|
||||||
|
|
||||||
log.info("Activity {} created and queued for async post-processing", activity.getId());
|
log.info("Activity {} created and queued for async post-processing", activity.getId());
|
||||||
|
|
@ -269,9 +264,15 @@ public class ActivityController {
|
||||||
request.getTitle(),
|
request.getTitle(),
|
||||||
request.getDescription(),
|
request.getDescription(),
|
||||||
request.getVisibility(),
|
request.getVisibility(),
|
||||||
|
request.getActivityType(),
|
||||||
request.getRace()
|
request.getRace()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Trigger federation on publish if visibility allows it
|
||||||
|
if (updated.getVisibility() != Activity.Visibility.PRIVATE) {
|
||||||
|
activityPostProcessingService.publishToFederationAsync(updated.getId(), userId);
|
||||||
|
}
|
||||||
|
|
||||||
ActivityDTO dto = ActivityDTO.fromEntity(updated);
|
ActivityDTO dto = ActivityDTO.fromEntity(updated);
|
||||||
return ResponseEntity.ok(dto);
|
return ResponseEntity.ok(dto);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
|
|
@ -302,9 +303,10 @@ public class ActivityController {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only send Delete activity if it was previously federated (public or followers-only)
|
// Only send Delete activity if it was previously published and federated
|
||||||
boolean shouldFederate = activity.getVisibility() == Activity.Visibility.PUBLIC ||
|
boolean shouldFederate = Boolean.TRUE.equals(activity.getPublished()) &&
|
||||||
activity.getVisibility() == Activity.Visibility.FOLLOWERS;
|
(activity.getVisibility() == Activity.Visibility.PUBLIC ||
|
||||||
|
activity.getVisibility() == Activity.Visibility.FOLLOWERS);
|
||||||
|
|
||||||
// Delete from database
|
// Delete from database
|
||||||
boolean deleted = fitFileService.deleteActivity(id, userId);
|
boolean deleted = fitFileService.deleteActivity(id, userId);
|
||||||
|
|
|
||||||
|
|
@ -27,5 +27,7 @@ public class ActivityUpdateRequest {
|
||||||
@NotNull(message = "Visibility is required")
|
@NotNull(message = "Visibility is required")
|
||||||
private Activity.Visibility visibility;
|
private Activity.Visibility visibility;
|
||||||
|
|
||||||
|
private Activity.ActivityType activityType;
|
||||||
|
|
||||||
private Boolean race; // Race/competition flag
|
private Boolean race; // Race/competition flag
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,15 @@ public class Activity {
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Boolean race = false;
|
private Boolean race = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this activity has been published (finalized by the user).
|
||||||
|
* New activities default to unpublished. Federation and public visibility
|
||||||
|
* are deferred until the user saves the activity details (title, description, visibility).
|
||||||
|
*/
|
||||||
|
@Column(name = "published", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private Boolean published = false;
|
||||||
|
|
||||||
@OneToOne(mappedBy = "activity", cascade = CascadeType.ALL, orphanRemoval = true)
|
@OneToOne(mappedBy = "activity", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
private ActivityMetrics metrics;
|
private ActivityMetrics metrics;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,6 @@ public interface WeatherDataRepository extends JpaRepository<WeatherData, UUID>
|
||||||
*/
|
*/
|
||||||
Optional<WeatherData> findByActivityId(UUID activityId);
|
Optional<WeatherData> findByActivityId(UUID activityId);
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if weather data exists for an activity.
|
|
||||||
*
|
|
||||||
* @param activityId the activity ID
|
|
||||||
* @return true if weather data exists
|
|
||||||
*/
|
|
||||||
boolean existsByActivityId(UUID activityId);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete weather data for an activity.
|
* Delete weather data for an activity.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,7 @@ public class ActivityFileService {
|
||||||
: ActivityFormatter.generateActivityTitle(parsedData.getStartTime(), parsedData.getActivityType());
|
: ActivityFormatter.generateActivityTitle(parsedData.getStartTime(), parsedData.getActivityType());
|
||||||
|
|
||||||
// Default to PUBLIC if visibility not specified
|
// Default to PUBLIC if visibility not specified
|
||||||
Activity.Visibility activityVisibility = visibility != null ? visibility : Activity.Visibility.PUBLIC;
|
Activity.Visibility activityVisibility = visibility != null ? visibility : Activity.Visibility.PRIVATE;
|
||||||
|
|
||||||
// Create activity entity
|
// Create activity entity
|
||||||
Activity activity = Activity.builder()
|
Activity activity = Activity.builder()
|
||||||
|
|
@ -301,10 +301,9 @@ public class ActivityFileService {
|
||||||
activity.setMetrics(metrics);
|
activity.setMetrics(metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
var res = activity.findFirstTrackpoint()
|
activity.findFirstTrackpoint()
|
||||||
.map(tp -> reverseGeolocationRepository.findForLocation(tp.lon(), tp.lat()));
|
.map(tp -> reverseGeolocationRepository.findForLocation(tp.lon(), tp.lat()))
|
||||||
|
.ifPresent(reverseGeolocation -> activity.setActivityLocation(reverseGeolocation.formatWithHighestResolution()));
|
||||||
res.ifPresent(reverseGeolocation -> activity.setActivityLocation(reverseGeolocation.formatWithHighestResolution()));
|
|
||||||
|
|
||||||
// Save activity (single INSERT instead of 855!)
|
// Save activity (single INSERT instead of 855!)
|
||||||
Activity savedActivity = activityRepository.save(activity);
|
Activity savedActivity = activityRepository.save(activity);
|
||||||
|
|
|
||||||
|
|
@ -83,13 +83,12 @@ public class ActivityImageService {
|
||||||
g2d.drawImage(mapTiles, 0, 0, null);
|
g2d.drawImage(mapTiles, 0, 0, null);
|
||||||
|
|
||||||
// 80s Aerobic style gradient background for metadata area (right 40%)
|
// 80s Aerobic style gradient background for metadata area (right 40%)
|
||||||
int metadataX = trackWidth;
|
|
||||||
GradientPaint gradient = new GradientPaint(
|
GradientPaint gradient = new GradientPaint(
|
||||||
metadataX, 0, new Color(26, 0, 51), // Dark purple
|
trackWidth, 0, new Color(26, 0, 51), // Dark purple
|
||||||
width, height, new Color(45, 0, 82) // Lighter purple
|
width, height, new Color(45, 0, 82) // Lighter purple
|
||||||
);
|
);
|
||||||
g2d.setPaint(gradient);
|
g2d.setPaint(gradient);
|
||||||
g2d.fillRect(metadataX, 0, width - metadataX, height);
|
g2d.fillRect(trackWidth, 0, width - trackWidth, height);
|
||||||
|
|
||||||
log.debug("Rendered OSM tiles for activity {}", activity.getId());
|
log.debug("Rendered OSM tiles for activity {}", activity.getId());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ import net.javahippie.fitpub.util.ActivityFormatter;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -21,16 +19,11 @@ import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for asynchronous post-processing of activities after upload.
|
* Service for asynchronous post-processing of activities after upload.
|
||||||
* Coordinates expensive operations (Personal Records, Weather, Heatmap, Federation)
|
* Coordinates expensive operations (Personal Records, Weather, Heatmap)
|
||||||
* in separate transactions to avoid blocking the upload response.
|
* to avoid blocking the upload response.
|
||||||
*
|
*
|
||||||
* Each operation runs asynchronously with REQUIRES_NEW transaction propagation
|
* Federation is NOT triggered here — it is deferred until the user
|
||||||
* to ensure fault isolation - failures in one operation don't affect others.
|
* finalizes the activity via the metadata update (PUT) endpoint.
|
||||||
*
|
|
||||||
* Operations execute in the following order:
|
|
||||||
* - Personal Records: Runs independently (parallel)
|
|
||||||
* - Heatmap: Runs independently (parallel)
|
|
||||||
* - Weather → Federation: Sequential chain (weather must complete before federation)
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
|
@ -52,10 +45,6 @@ public class ActivityPostProcessingService {
|
||||||
* Orchestrates async post-processing operations for an uploaded activity.
|
* Orchestrates async post-processing operations for an uploaded activity.
|
||||||
* Called after activity is saved and immediately visible to the user.
|
* Called after activity is saved and immediately visible to the user.
|
||||||
*
|
*
|
||||||
* Personal Records and Heatmap run independently in parallel.
|
|
||||||
* Weather and Federation run sequentially (weather must complete first for future share pic integration).
|
|
||||||
*
|
|
||||||
* All operations use separate transactions (REQUIRES_NEW) for fault isolation.
|
|
||||||
* Errors are logged but don't propagate - each operation fails independently.
|
* Errors are logged but don't propagate - each operation fails independently.
|
||||||
*
|
*
|
||||||
* @param activityId the saved activity ID
|
* @param activityId the saved activity ID
|
||||||
|
|
@ -65,28 +54,18 @@ public class ActivityPostProcessingService {
|
||||||
public void processActivityAsync(UUID activityId, UUID userId) {
|
public void processActivityAsync(UUID activityId, UUID userId) {
|
||||||
log.info("Starting async post-processing for activity {} by user {}", activityId, userId);
|
log.info("Starting async post-processing for activity {} by user {}", activityId, userId);
|
||||||
|
|
||||||
// Run post-processing operations in background thread
|
|
||||||
// All operations run sequentially with separate transactions (REQUIRES_NEW)
|
|
||||||
// for fault isolation - failures in one operation don't affect others
|
|
||||||
|
|
||||||
updatePersonalRecordsAsync(activityId);
|
updatePersonalRecordsAsync(activityId);
|
||||||
updateHeatmapAsync(activityId);
|
updateHeatmapAsync(activityId);
|
||||||
|
|
||||||
// Weather must complete before federation for potential weather data in share images
|
|
||||||
fetchWeatherAsync(activityId);
|
fetchWeatherAsync(activityId);
|
||||||
publishToFederationAsync(activityId, userId);
|
|
||||||
|
|
||||||
log.info("Completed async post-processing for activity {}", activityId);
|
log.info("Completed async post-processing for activity {}", activityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check and update personal records for the activity.
|
* Check and update personal records for the activity.
|
||||||
* Called internally from processActivityAsync background thread.
|
|
||||||
* Runs in a separate transaction to isolate from main upload transaction.
|
|
||||||
*
|
*
|
||||||
* @param activityId the activity ID to process
|
* @param activityId the activity ID to process
|
||||||
*/
|
*/
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
void updatePersonalRecordsAsync(UUID activityId) {
|
void updatePersonalRecordsAsync(UUID activityId) {
|
||||||
try {
|
try {
|
||||||
log.debug("Async: Checking personal records for activity {}", activityId);
|
log.debug("Async: Checking personal records for activity {}", activityId);
|
||||||
|
|
@ -107,12 +86,9 @@ public class ActivityPostProcessingService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update heatmap grid with activity GPS data.
|
* Update heatmap grid with activity GPS data.
|
||||||
* Called internally from processActivityAsync background thread.
|
|
||||||
* Runs in a separate transaction to isolate from main upload transaction.
|
|
||||||
*
|
*
|
||||||
* @param activityId the activity ID to process
|
* @param activityId the activity ID to process
|
||||||
*/
|
*/
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
void updateHeatmapAsync(UUID activityId) {
|
void updateHeatmapAsync(UUID activityId) {
|
||||||
try {
|
try {
|
||||||
log.debug("Async: Updating heatmap for activity {}", activityId);
|
log.debug("Async: Updating heatmap for activity {}", activityId);
|
||||||
|
|
@ -133,14 +109,9 @@ public class ActivityPostProcessingService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch weather data for the activity location and time.
|
* Fetch weather data for the activity location and time.
|
||||||
* Called internally from processActivityAsync background thread.
|
|
||||||
* Runs in a separate transaction to isolate from main upload transaction.
|
|
||||||
*
|
|
||||||
* Must complete before federation push to allow future integration of weather in share images.
|
|
||||||
*
|
*
|
||||||
* @param activityId the activity ID to process
|
* @param activityId the activity ID to process
|
||||||
*/
|
*/
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
void fetchWeatherAsync(UUID activityId) {
|
void fetchWeatherAsync(UUID activityId) {
|
||||||
try {
|
try {
|
||||||
log.debug("Async: Fetching weather for activity {}", activityId);
|
log.debug("Async: Fetching weather for activity {}", activityId);
|
||||||
|
|
@ -162,17 +133,15 @@ public class ActivityPostProcessingService {
|
||||||
/**
|
/**
|
||||||
* Publish activity to the Fediverse (ActivityPub federation).
|
* Publish activity to the Fediverse (ActivityPub federation).
|
||||||
* Generates activity image and sends Create activity to all follower inboxes.
|
* Generates activity image and sends Create activity to all follower inboxes.
|
||||||
* Called internally from processActivityAsync background thread.
|
|
||||||
* Runs in a separate transaction to isolate from main upload transaction.
|
|
||||||
*
|
*
|
||||||
* Only publishes if activity visibility is PUBLIC or FOLLOWERS.
|
* Only publishes if activity visibility is PUBLIC or FOLLOWERS.
|
||||||
* This method should run AFTER weather fetch completes for future share pic integration.
|
* Called from the controller when the user finalizes activity metadata.
|
||||||
*
|
*
|
||||||
* @param activityId the activity ID to publish
|
* @param activityId the activity ID to publish
|
||||||
* @param userId the user ID who owns the activity
|
* @param userId the user ID who owns the activity
|
||||||
*/
|
*/
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
@Async("taskExecutor")
|
||||||
void publishToFederationAsync(UUID activityId, UUID userId) {
|
public void publishToFederationAsync(UUID activityId, UUID userId) {
|
||||||
try {
|
try {
|
||||||
log.debug("Async: Publishing activity {} to Fediverse", activityId);
|
log.debug("Async: Publishing activity {} to Fediverse", activityId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -436,7 +436,7 @@ public class FitFileService {
|
||||||
* @throws IllegalArgumentException if activity doesn't exist or user doesn't own it
|
* @throws IllegalArgumentException if activity doesn't exist or user doesn't own it
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public Activity updateActivity(UUID activityId, UUID userId, String title, String description, Activity.Visibility visibility, Boolean race) {
|
public Activity updateActivity(UUID activityId, UUID userId, String title, String description, Activity.Visibility visibility, Activity.ActivityType activityType, Boolean race) {
|
||||||
// Fetch the existing activity within the transaction
|
// Fetch the existing activity within the transaction
|
||||||
Activity existing = activityRepository.findByIdAndUserId(activityId, userId)
|
Activity existing = activityRepository.findByIdAndUserId(activityId, userId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Activity not found or user does not own it: " + activityId));
|
.orElseThrow(() -> new IllegalArgumentException("Activity not found or user does not own it: " + activityId));
|
||||||
|
|
@ -445,7 +445,11 @@ public class FitFileService {
|
||||||
existing.setTitle(title);
|
existing.setTitle(title);
|
||||||
existing.setDescription(description);
|
existing.setDescription(description);
|
||||||
existing.setVisibility(visibility);
|
existing.setVisibility(visibility);
|
||||||
|
if (activityType != null) {
|
||||||
|
existing.setActivityType(activityType);
|
||||||
|
}
|
||||||
existing.setRace(race != null ? race : false);
|
existing.setRace(race != null ? race : false);
|
||||||
|
existing.setPublished(true);
|
||||||
|
|
||||||
// Save will UPDATE because the entity is already managed by the persistence context
|
// Save will UPDATE because the entity is already managed by the persistence context
|
||||||
return activityRepository.save(existing);
|
return activityRepository.save(existing);
|
||||||
|
|
|
||||||
|
|
@ -74,13 +74,6 @@ public class WeatherService {
|
||||||
log.debug("Weather API key present: length={} chars, first 4 chars={}...",
|
log.debug("Weather API key present: length={} chars, first 4 chars={}...",
|
||||||
apiKey.length(), apiKey.length() > 4 ? apiKey.substring(0, 4) : "???");
|
apiKey.length(), apiKey.length() > 4 ? apiKey.substring(0, 4) : "???");
|
||||||
|
|
||||||
// Check if weather data already exists
|
|
||||||
if (weatherDataRepository.existsByActivityId(activity.getId())) {
|
|
||||||
log.info("Weather data already exists for activity {}, returning cached data", activity.getId());
|
|
||||||
return weatherDataRepository.findByActivityId(activity.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract start location from track
|
|
||||||
if (activity.getTrackPointsJson() == null || activity.getTrackPointsJson().isEmpty()) {
|
if (activity.getTrackPointsJson() == null || activity.getTrackPointsJson().isEmpty()) {
|
||||||
log.warn("No track points available for activity {} - cannot fetch weather", activity.getId());
|
log.warn("No track points available for activity {} - cannot fetch weather", activity.getId());
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Create with default true to update all existing activities to 'published'
|
||||||
|
alter table activities
|
||||||
|
add column published BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
|
||||||
|
-- Change the default
|
||||||
|
alter table activities
|
||||||
|
alter column published SET DEFAULT FALSE;
|
||||||
|
|
@ -17,13 +17,6 @@
|
||||||
Upload Activity
|
Upload Activity
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Success Alert -->
|
|
||||||
<div id="successAlert" class="alert alert-success d-none" role="alert">
|
|
||||||
<i class="bi bi-check-circle-fill"></i>
|
|
||||||
<span id="successMessage">Activity uploaded successfully!</span>
|
|
||||||
<a href="#" id="viewActivityLink" class="alert-link">View activity</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error Alert -->
|
<!-- Error Alert -->
|
||||||
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
|
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
|
||||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||||
|
|
@ -36,9 +29,6 @@
|
||||||
<form id="uploadForm" enctype="multipart/form-data">
|
<form id="uploadForm" enctype="multipart/form-data">
|
||||||
<!-- File Upload Area -->
|
<!-- File Upload Area -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="form-label fw-bold">
|
|
||||||
Activity File <span class="text-danger">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="file-upload-area" id="fileUploadArea">
|
<div class="file-upload-area" id="fileUploadArea">
|
||||||
<input type="file"
|
<input type="file"
|
||||||
id="fitFile"
|
id="fitFile"
|
||||||
|
|
@ -99,6 +89,33 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity Type -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="activityType" class="form-label">
|
||||||
|
Activity Type <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="activityType" name="activityType" required>
|
||||||
|
<option value="RUN">Run</option>
|
||||||
|
<option value="RIDE">Ride</option>
|
||||||
|
<option value="HIKE">Hike</option>
|
||||||
|
<option value="WALK">Walk</option>
|
||||||
|
<option value="SWIM">Swim</option>
|
||||||
|
<option value="ALPINE_SKI">Alpine Ski</option>
|
||||||
|
<option value="BACKCOUNTRY_SKI">Backcountry Ski</option>
|
||||||
|
<option value="NORDIC_SKI">Nordic Ski</option>
|
||||||
|
<option value="SNOWBOARD">Snowboard</option>
|
||||||
|
<option value="ROWING">Rowing</option>
|
||||||
|
<option value="KAYAKING">Kayaking</option>
|
||||||
|
<option value="CANOEING">Canoeing</option>
|
||||||
|
<option value="INLINE_SKATING">Inline Skating</option>
|
||||||
|
<option value="ROCK_CLIMBING">Rock Climbing</option>
|
||||||
|
<option value="MOUNTAINEERING">Mountaineering</option>
|
||||||
|
<option value="YOGA">Yoga</option>
|
||||||
|
<option value="WORKOUT">Workout</option>
|
||||||
|
<option value="OTHER">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="description" class="form-label">
|
<label for="description" class="form-label">
|
||||||
|
|
@ -121,9 +138,9 @@
|
||||||
Visibility <span class="text-danger">*</span>
|
Visibility <span class="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select class="form-select" id="visibility" name="visibility">
|
<select class="form-select" id="visibility" name="visibility">
|
||||||
<option value="PUBLIC" selected>Public - Anyone can see</option>
|
<option value="PUBLIC">Public - Anyone can see</option>
|
||||||
<option value="FOLLOWERS">Followers Only - Only your followers can see</option>
|
<option value="FOLLOWERS">Followers Only - Only your followers can see</option>
|
||||||
<option value="PRIVATE">Private - Only you can see</option>
|
<option value="PRIVATE" selected>Private - Only you can see</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
<i class="bi bi-info-circle"></i>
|
<i class="bi bi-info-circle"></i>
|
||||||
|
|
@ -194,6 +211,7 @@
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const form = document.getElementById('uploadForm');
|
const form = document.getElementById('uploadForm');
|
||||||
|
const fileUploadArea = document.getElementById('fileUploadArea');
|
||||||
const fitFileInput = document.getElementById('fitFile');
|
const fitFileInput = document.getElementById('fitFile');
|
||||||
const fileLabel = document.getElementById('fileLabel');
|
const fileLabel = document.getElementById('fileLabel');
|
||||||
const uploadBtn = document.getElementById('uploadBtn');
|
const uploadBtn = document.getElementById('uploadBtn');
|
||||||
|
|
@ -201,7 +219,6 @@
|
||||||
const uploadBtnSpinner = document.getElementById('uploadBtnSpinner');
|
const uploadBtnSpinner = document.getElementById('uploadBtnSpinner');
|
||||||
const cancelBtn = document.getElementById('cancelBtn');
|
const cancelBtn = document.getElementById('cancelBtn');
|
||||||
const errorAlert = document.getElementById('errorAlert');
|
const errorAlert = document.getElementById('errorAlert');
|
||||||
const successAlert = document.getElementById('successAlert');
|
|
||||||
const errorMessage = document.getElementById('errorMessage');
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
const progressContainer = document.getElementById('progressContainer');
|
const progressContainer = document.getElementById('progressContainer');
|
||||||
const progressBar = document.getElementById('progressBar');
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
|
@ -270,7 +287,6 @@
|
||||||
|
|
||||||
// Hide alerts
|
// Hide alerts
|
||||||
errorAlert.classList.add('d-none');
|
errorAlert.classList.add('d-none');
|
||||||
successAlert.classList.add('d-none');
|
|
||||||
|
|
||||||
// Show progress
|
// Show progress
|
||||||
progressContainer.classList.remove('d-none');
|
progressContainer.classList.remove('d-none');
|
||||||
|
|
@ -309,9 +325,9 @@
|
||||||
progressBar.classList.remove('progress-bar-animated');
|
progressBar.classList.remove('progress-bar-animated');
|
||||||
progressBar.classList.add('bg-success');
|
progressBar.classList.add('bg-success');
|
||||||
|
|
||||||
// Show success message
|
// Hide upload form
|
||||||
successAlert.classList.remove('d-none');
|
fileUploadArea.classList.add('d-none');
|
||||||
document.getElementById('viewActivityLink').href = '/activities/' + uploadedActivityId;
|
progressContainer.classList.add('d-none');
|
||||||
|
|
||||||
// Show metadata section if not already shown
|
// Show metadata section if not already shown
|
||||||
if (metadataSection.classList.contains('d-none')) {
|
if (metadataSection.classList.contains('d-none')) {
|
||||||
|
|
@ -333,8 +349,15 @@
|
||||||
<p class="mb-0"><strong>Date:</strong> ${formattedDateTime}</p>
|
<p class="mb-0"><strong>Date:</strong> ${formattedDateTime}</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Pre-fill title with activity type and date
|
// Use server-generated title (e.g. "Evening Run")
|
||||||
document.getElementById('title').value = `${response.activityType || 'Activity'} - ${formattedDate}`;
|
document.getElementById('title').value = response.title || `${response.activityType || 'Activity'} - ${formattedDate}`;
|
||||||
|
|
||||||
|
// Pre-select activity type dropdown
|
||||||
|
const activityTypeRaw = (response.activityType || '').toUpperCase().replace(/ /g, '_');
|
||||||
|
const activityTypeSelect = document.getElementById('activityType');
|
||||||
|
if (activityTypeSelect.querySelector(`option[value="${activityTypeRaw}"]`)) {
|
||||||
|
activityTypeSelect.value = activityTypeRaw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset form state
|
// Reset form state
|
||||||
|
|
@ -425,13 +448,13 @@
|
||||||
|
|
||||||
// Hide alerts
|
// Hide alerts
|
||||||
errorAlert.classList.add('d-none');
|
errorAlert.classList.add('d-none');
|
||||||
successAlert.classList.add('d-none');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updateData = {
|
const updateData = {
|
||||||
title: document.getElementById('title').value,
|
title: document.getElementById('title').value,
|
||||||
description: document.getElementById('description').value,
|
description: document.getElementById('description').value,
|
||||||
visibility: document.getElementById('visibility').value,
|
visibility: document.getElementById('visibility').value,
|
||||||
|
activityType: document.getElementById('activityType').value,
|
||||||
race: document.getElementById('race').checked
|
race: document.getElementById('race').checked
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,17 @@ package net.javahippie.fitpub.config;
|
||||||
import org.springframework.boot.test.context.TestConfiguration;
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.testcontainers.containers.BindMode;
|
|
||||||
import org.testcontainers.containers.PostgreSQLContainer;
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
import org.testcontainers.containers.startupcheck.IsRunningStartupCheckStrategy;
|
|
||||||
import org.testcontainers.containers.startupcheck.StartupCheckStrategy;
|
|
||||||
import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
|
import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
|
||||||
import org.testcontainers.utility.DockerImageName;
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Testcontainers configuration for tests.
|
* Testcontainers configuration for tests and dev mode (via spring-boot:test-run).
|
||||||
* Automatically starts a PostgreSQL container with PostGIS extension for integration tests.
|
* Automatically starts a PostgreSQL container with PostGIS extension.
|
||||||
*/
|
*/
|
||||||
@TestConfiguration(proxyBeanMethods = false)
|
@TestConfiguration(proxyBeanMethods = false)
|
||||||
public class TestcontainersConfiguration {
|
public class TestcontainersConfiguration {
|
||||||
|
|
||||||
/**
|
|
||||||
* PostgreSQL container with PostGIS extension for tests.
|
|
||||||
* PostGIS image is treated as a standard PostgreSQL container.
|
|
||||||
*
|
|
||||||
* @ServiceConnection automatically configures the datasource from this container.
|
|
||||||
*/
|
|
||||||
@Bean
|
@Bean
|
||||||
@ServiceConnection
|
@ServiceConnection
|
||||||
public PostgreSQLContainer<?> postgresContainer() {
|
public PostgreSQLContainer<?> postgresContainer() {
|
||||||
|
|
@ -34,7 +25,6 @@ public class TestcontainersConfiguration {
|
||||||
.withUsername("test")
|
.withUsername("test")
|
||||||
.withPassword("test")
|
.withPassword("test")
|
||||||
.waitingFor(new HostPortWaitStrategy())
|
.waitingFor(new HostPortWaitStrategy())
|
||||||
.withReuse(true)
|
.withReuse(true);
|
||||||
.withFileSystemBind(".postgresdata", "/var/lib/postgresql/data", BindMode.READ_WRITE);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,6 @@ class WeatherServiceTest {
|
||||||
""";
|
""";
|
||||||
testActivity.setTrackPointsJson(trackPointsJson);
|
testActivity.setTrackPointsJson(trackPointsJson);
|
||||||
|
|
||||||
when(weatherDataRepository.existsByActivityId(activityId)).thenReturn(false);
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||||
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
||||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||||
|
|
@ -163,7 +162,6 @@ class WeatherServiceTest {
|
||||||
""";
|
""";
|
||||||
testActivity.setTrackPointsJson(trackPointsJson);
|
testActivity.setTrackPointsJson(trackPointsJson);
|
||||||
|
|
||||||
when(weatherDataRepository.existsByActivityId(activityId)).thenReturn(false);
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||||
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
||||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||||
|
|
@ -211,26 +209,6 @@ class WeatherServiceTest {
|
||||||
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
|
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should return cached weather if it already exists")
|
|
||||||
void testFetchWeather_Cached() {
|
|
||||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
|
||||||
|
|
||||||
WeatherData cachedWeather = new WeatherData();
|
|
||||||
cachedWeather.setActivityId(activityId);
|
|
||||||
cachedWeather.setTemperatureCelsius(new BigDecimal("20.0"));
|
|
||||||
|
|
||||||
when(weatherDataRepository.existsByActivityId(activityId)).thenReturn(true);
|
|
||||||
when(weatherDataRepository.findByActivityId(activityId)).thenReturn(Optional.of(cachedWeather));
|
|
||||||
|
|
||||||
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
|
||||||
|
|
||||||
assertTrue(result.isPresent());
|
|
||||||
assertEquals(new BigDecimal("20.0"), result.get().getTemperatureCelsius());
|
|
||||||
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
|
|
||||||
verify(weatherDataRepository, never()).save(any(WeatherData.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Should return empty when track points JSON is null")
|
@DisplayName("Should return empty when track points JSON is null")
|
||||||
void testFetchWeather_NoTrackPoints() {
|
void testFetchWeather_NoTrackPoints() {
|
||||||
|
|
@ -291,8 +269,6 @@ class WeatherServiceTest {
|
||||||
testActivity.setStartedAt(LocalDateTime.now().minusDays(10)); // Old activity
|
testActivity.setStartedAt(LocalDateTime.now().minusDays(10)); // Old activity
|
||||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||||
|
|
||||||
when(weatherDataRepository.existsByActivityId(activityId)).thenReturn(false);
|
|
||||||
|
|
||||||
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
||||||
|
|
||||||
assertTrue(result.isEmpty());
|
assertTrue(result.isEmpty());
|
||||||
|
|
@ -305,7 +281,6 @@ class WeatherServiceTest {
|
||||||
void testFetchWeather_AuthenticationError() {
|
void testFetchWeather_AuthenticationError() {
|
||||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||||
|
|
||||||
when(weatherDataRepository.existsByActivityId(activityId)).thenReturn(false);
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||||
.thenThrow(new HttpClientErrorException(
|
.thenThrow(new HttpClientErrorException(
|
||||||
org.springframework.http.HttpStatus.UNAUTHORIZED,
|
org.springframework.http.HttpStatus.UNAUTHORIZED,
|
||||||
|
|
@ -325,7 +300,6 @@ class WeatherServiceTest {
|
||||||
void testFetchWeather_NetworkError() {
|
void testFetchWeather_NetworkError() {
|
||||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||||
|
|
||||||
when(weatherDataRepository.existsByActivityId(activityId)).thenReturn(false);
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||||
.thenThrow(new ResourceAccessException("Connection timeout"));
|
.thenThrow(new ResourceAccessException("Connection timeout"));
|
||||||
|
|
||||||
|
|
@ -340,7 +314,6 @@ class WeatherServiceTest {
|
||||||
void testFetchWeather_MalformedResponse() {
|
void testFetchWeather_MalformedResponse() {
|
||||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||||
|
|
||||||
when(weatherDataRepository.existsByActivityId(activityId)).thenReturn(false);
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||||
.thenReturn("this is not valid JSON");
|
.thenReturn("this is not valid JSON");
|
||||||
|
|
||||||
|
|
@ -372,7 +345,6 @@ class WeatherServiceTest {
|
||||||
|
|
||||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||||
|
|
||||||
when(weatherDataRepository.existsByActivityId(activityId)).thenReturn(false);
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||||
.thenReturn(responseWithRain);
|
.thenReturn(responseWithRain);
|
||||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||||
|
|
@ -414,7 +386,6 @@ class WeatherServiceTest {
|
||||||
|
|
||||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||||
|
|
||||||
when(weatherDataRepository.existsByActivityId(activityId)).thenReturn(false);
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||||
.thenReturn(minimalResponse);
|
.thenReturn(minimalResponse);
|
||||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||||
|
|
@ -470,7 +441,6 @@ class WeatherServiceTest {
|
||||||
""";
|
""";
|
||||||
testActivity.setTrackPointsJson(trackPointsJson);
|
testActivity.setTrackPointsJson(trackPointsJson);
|
||||||
|
|
||||||
when(weatherDataRepository.existsByActivityId(activityId)).thenReturn(false);
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||||
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
||||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ public class FitFileAnalyzer {
|
||||||
private static final double SEMICIRCLES_TO_DEGREES = 180.0 / Math.pow(2, 31);
|
private static final double SEMICIRCLES_TO_DEGREES = 180.0 / Math.pow(2, 31);
|
||||||
private static final long FIT_EPOCH_OFFSET = 631065600L;
|
private static final long FIT_EPOCH_OFFSET = 631065600L;
|
||||||
|
|
||||||
public static void main(String[] args) {
|
/**
|
||||||
|
* Run from IDE or via {@code analyzeFitFile(path)} directly.
|
||||||
|
*/
|
||||||
|
public static void analyze(String[] args) {
|
||||||
if (args.length == 0) {
|
if (args.length == 0) {
|
||||||
System.out.println("Usage: FitFileAnalyzer <path-to-fit-file>");
|
System.out.println("Usage: FitFileAnalyzer <path-to-fit-file>");
|
||||||
System.exit(1);
|
System.exit(1);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue