diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml
index 9605f07..ed1c16b 100644
--- a/.idea/data_source_mapping.xml
+++ b/.idea/data_source_mapping.xml
@@ -1,7 +1,6 @@
-
\ No newline at end of file
diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml
index 69328be..27a4b8c 100644
--- a/.idea/sqldialects.xml
+++ b/.idea/sqldialects.xml
@@ -3,5 +3,6 @@
+
\ No newline at end of file
diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityController.java
index 26afcdd..b9589d6 100644
--- a/src/main/java/net/javahippie/fitpub/controller/ActivityController.java
+++ b/src/main/java/net/javahippie/fitpub/controller/ActivityController.java
@@ -120,13 +120,8 @@ public class ActivityController {
// - Personal Records checking
// - Weather data fetching
// - Heatmap grid updates
- // - Federation push (includes image generation)
//
- // Operations run in separate transactions with proper ordering:
- // - 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
+ // Federation is deferred until the user finalizes via PUT (metadata update)
activityPostProcessingService.processActivityAsync(activity.getId(), user.getId());
log.info("Activity {} created and queued for async post-processing", activity.getId());
@@ -269,9 +264,15 @@ public class ActivityController {
request.getTitle(),
request.getDescription(),
request.getVisibility(),
+ request.getActivityType(),
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);
return ResponseEntity.ok(dto);
} catch (IllegalArgumentException e) {
@@ -302,9 +303,10 @@ public class ActivityController {
return ResponseEntity.notFound().build();
}
- // Only send Delete activity if it was previously federated (public or followers-only)
- boolean shouldFederate = activity.getVisibility() == Activity.Visibility.PUBLIC ||
- activity.getVisibility() == Activity.Visibility.FOLLOWERS;
+ // Only send Delete activity if it was previously published and federated
+ boolean shouldFederate = Boolean.TRUE.equals(activity.getPublished()) &&
+ (activity.getVisibility() == Activity.Visibility.PUBLIC ||
+ activity.getVisibility() == Activity.Visibility.FOLLOWERS);
// Delete from database
boolean deleted = fitFileService.deleteActivity(id, userId);
diff --git a/src/main/java/net/javahippie/fitpub/model/dto/ActivityUpdateRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/ActivityUpdateRequest.java
index 6ca085a..6798064 100644
--- a/src/main/java/net/javahippie/fitpub/model/dto/ActivityUpdateRequest.java
+++ b/src/main/java/net/javahippie/fitpub/model/dto/ActivityUpdateRequest.java
@@ -27,5 +27,7 @@ public class ActivityUpdateRequest {
@NotNull(message = "Visibility is required")
private Activity.Visibility visibility;
+ private Activity.ActivityType activityType;
+
private Boolean race; // Race/competition flag
}
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 477a7ec..ac6a63e 100644
--- a/src/main/java/net/javahippie/fitpub/model/entity/Activity.java
+++ b/src/main/java/net/javahippie/fitpub/model/entity/Activity.java
@@ -148,6 +148,15 @@ public class Activity {
@Builder.Default
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)
private ActivityMetrics metrics;
diff --git a/src/main/java/net/javahippie/fitpub/repository/WeatherDataRepository.java b/src/main/java/net/javahippie/fitpub/repository/WeatherDataRepository.java
index 581f4b0..4bd2d44 100644
--- a/src/main/java/net/javahippie/fitpub/repository/WeatherDataRepository.java
+++ b/src/main/java/net/javahippie/fitpub/repository/WeatherDataRepository.java
@@ -21,14 +21,6 @@ public interface WeatherDataRepository extends JpaRepository
*/
Optional 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.
*
diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java b/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java
index 8f965a7..98a5c7d 100644
--- a/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java
+++ b/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java
@@ -252,7 +252,7 @@ public class ActivityFileService {
: ActivityFormatter.generateActivityTitle(parsedData.getStartTime(), parsedData.getActivityType());
// 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
Activity activity = Activity.builder()
@@ -301,10 +301,9 @@ public class ActivityFileService {
activity.setMetrics(metrics);
}
- var res = activity.findFirstTrackpoint()
- .map(tp -> reverseGeolocationRepository.findForLocation(tp.lon(), tp.lat()));
-
- res.ifPresent(reverseGeolocation -> activity.setActivityLocation(reverseGeolocation.formatWithHighestResolution()));
+ activity.findFirstTrackpoint()
+ .map(tp -> reverseGeolocationRepository.findForLocation(tp.lon(), tp.lat()))
+ .ifPresent(reverseGeolocation -> activity.setActivityLocation(reverseGeolocation.formatWithHighestResolution()));
// Save activity (single INSERT instead of 855!)
Activity savedActivity = activityRepository.save(activity);
diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityImageService.java b/src/main/java/net/javahippie/fitpub/service/ActivityImageService.java
index 3ab1f2c..5d2cf86 100644
--- a/src/main/java/net/javahippie/fitpub/service/ActivityImageService.java
+++ b/src/main/java/net/javahippie/fitpub/service/ActivityImageService.java
@@ -83,13 +83,12 @@ public class ActivityImageService {
g2d.drawImage(mapTiles, 0, 0, null);
// 80s Aerobic style gradient background for metadata area (right 40%)
- int metadataX = trackWidth;
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
);
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());
} catch (Exception e) {
diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java
index 0182f26..fef8473 100644
--- a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java
+++ b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java
@@ -11,8 +11,6 @@ import net.javahippie.fitpub.util.ActivityFormatter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Propagation;
-import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
@@ -21,16 +19,11 @@ import java.util.UUID;
/**
* Service for asynchronous post-processing of activities after upload.
- * Coordinates expensive operations (Personal Records, Weather, Heatmap, Federation)
- * in separate transactions to avoid blocking the upload response.
+ * Coordinates expensive operations (Personal Records, Weather, Heatmap)
+ * to avoid blocking the upload response.
*
- * Each operation runs asynchronously with REQUIRES_NEW transaction propagation
- * to ensure fault isolation - failures in one operation don't affect others.
- *
- * Operations execute in the following order:
- * - Personal Records: Runs independently (parallel)
- * - Heatmap: Runs independently (parallel)
- * - Weather → Federation: Sequential chain (weather must complete before federation)
+ * Federation is NOT triggered here — it is deferred until the user
+ * finalizes the activity via the metadata update (PUT) endpoint.
*/
@Service
@RequiredArgsConstructor
@@ -52,10 +45,6 @@ public class ActivityPostProcessingService {
* Orchestrates async post-processing operations for an uploaded activity.
* 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.
*
* @param activityId the saved activity ID
@@ -65,28 +54,18 @@ public class ActivityPostProcessingService {
public void processActivityAsync(UUID activityId, UUID 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);
updateHeatmapAsync(activityId);
-
- // Weather must complete before federation for potential weather data in share images
fetchWeatherAsync(activityId);
- publishToFederationAsync(activityId, userId);
log.info("Completed async post-processing for activity {}", activityId);
}
/**
* 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
*/
- @Transactional(propagation = Propagation.REQUIRES_NEW)
void updatePersonalRecordsAsync(UUID activityId) {
try {
log.debug("Async: Checking personal records for activity {}", activityId);
@@ -107,12 +86,9 @@ public class ActivityPostProcessingService {
/**
* 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
*/
- @Transactional(propagation = Propagation.REQUIRES_NEW)
void updateHeatmapAsync(UUID activityId) {
try {
log.debug("Async: Updating heatmap for activity {}", activityId);
@@ -133,14 +109,9 @@ public class ActivityPostProcessingService {
/**
* 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
*/
- @Transactional(propagation = Propagation.REQUIRES_NEW)
void fetchWeatherAsync(UUID activityId) {
try {
log.debug("Async: Fetching weather for activity {}", activityId);
@@ -162,17 +133,15 @@ public class ActivityPostProcessingService {
/**
* Publish activity to the Fediverse (ActivityPub federation).
* 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.
- * 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 userId the user ID who owns the activity
*/
- @Transactional(propagation = Propagation.REQUIRES_NEW)
- void publishToFederationAsync(UUID activityId, UUID userId) {
+ @Async("taskExecutor")
+ public void publishToFederationAsync(UUID activityId, UUID userId) {
try {
log.debug("Async: Publishing activity {} to Fediverse", activityId);
diff --git a/src/main/java/net/javahippie/fitpub/service/FitFileService.java b/src/main/java/net/javahippie/fitpub/service/FitFileService.java
index 6fcb191..a709797 100644
--- a/src/main/java/net/javahippie/fitpub/service/FitFileService.java
+++ b/src/main/java/net/javahippie/fitpub/service/FitFileService.java
@@ -436,7 +436,7 @@ public class FitFileService {
* @throws IllegalArgumentException if activity doesn't exist or user doesn't own it
*/
@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
Activity existing = activityRepository.findByIdAndUserId(activityId, userId)
.orElseThrow(() -> new IllegalArgumentException("Activity not found or user does not own it: " + activityId));
@@ -445,7 +445,11 @@ public class FitFileService {
existing.setTitle(title);
existing.setDescription(description);
existing.setVisibility(visibility);
+ if (activityType != null) {
+ existing.setActivityType(activityType);
+ }
existing.setRace(race != null ? race : false);
+ existing.setPublished(true);
// Save will UPDATE because the entity is already managed by the persistence context
return activityRepository.save(existing);
diff --git a/src/main/java/net/javahippie/fitpub/service/WeatherService.java b/src/main/java/net/javahippie/fitpub/service/WeatherService.java
index 8aec90c..8c3a65c 100644
--- a/src/main/java/net/javahippie/fitpub/service/WeatherService.java
+++ b/src/main/java/net/javahippie/fitpub/service/WeatherService.java
@@ -74,13 +74,6 @@ public class WeatherService {
log.debug("Weather API key present: length={} chars, first 4 chars={}...",
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()) {
log.warn("No track points available for activity {} - cannot fetch weather", activity.getId());
return Optional.empty();
diff --git a/src/main/resources/db/migration/V26__add_published_to_activities.sql b/src/main/resources/db/migration/V26__add_published_to_activities.sql
new file mode 100644
index 0000000..5a55fc7
--- /dev/null
+++ b/src/main/resources/db/migration/V26__add_published_to_activities.sql
@@ -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;
\ No newline at end of file
diff --git a/src/main/resources/templates/activities/upload.html b/src/main/resources/templates/activities/upload.html
index c355aa3..145cf1f 100644
--- a/src/main/resources/templates/activities/upload.html
+++ b/src/main/resources/templates/activities/upload.html
@@ -17,13 +17,6 @@
Upload Activity
-
-