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