diff --git a/src/main/java/org/operaton/fitpub/controller/BatchImportController.java b/src/main/java/org/operaton/fitpub/controller/BatchImportController.java index 6d7a6fb..b629c88 100644 --- a/src/main/java/org/operaton/fitpub/controller/BatchImportController.java +++ b/src/main/java/org/operaton/fitpub/controller/BatchImportController.java @@ -197,6 +197,47 @@ public class BatchImportController { } } + /** + * Undoes a batch import by deleting all successfully imported activities. + * This operation cannot be reversed and runs asynchronously. + * + * DELETE /api/batch-import/jobs/{jobId} + * + * @param jobId the batch import job ID + * @param authentication the authenticated user + * @return 202 Accepted - deletion is processing asynchronously + */ + @DeleteMapping("/jobs/{jobId}") + public ResponseEntity undoBatchImport( + @PathVariable UUID jobId, + Authentication authentication + ) { + try { + String username = authentication.getName(); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + username)); + + log.info("User {} requesting to undo batch import job {}", username, jobId); + + // Initiate async undo (validates job and starts background deletion) + batchImportService.undoBatchImport(jobId, user.getId()); + + log.info("Batch import job {} undo initiated for user {}", jobId, username); + + return ResponseEntity.status(HttpStatus.ACCEPTED).body(new UndoResponse( + "Batch import undo initiated. Activities are being deleted in the background." + )); + + } catch (IllegalArgumentException e) { + log.warn("Invalid undo request: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse(e.getMessage())); + } catch (Exception e) { + log.error("Failed to initiate undo batch import", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("Failed to initiate undo: " + e.getMessage())); + } + } + /** * Maps BatchImportJob entity to DTO. */ @@ -274,4 +315,10 @@ public class BatchImportController { */ public record ErrorResponse(String error) { } + + /** + * DTO for undo response. + */ + public record UndoResponse(String message) { + } } diff --git a/src/main/java/org/operaton/fitpub/repository/ActivityRepository.java b/src/main/java/org/operaton/fitpub/repository/ActivityRepository.java index b9d61f1..093b355 100644 --- a/src/main/java/org/operaton/fitpub/repository/ActivityRepository.java +++ b/src/main/java/org/operaton/fitpub/repository/ActivityRepository.java @@ -4,6 +4,7 @@ import org.operaton.fitpub.model.entity.Activity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -184,4 +185,15 @@ public interface ActivityRepository extends JpaRepository { "WHERE a.userId = :userId " + "AND FUNCTION('DATE', a.startedAt) = :date") boolean existsByUserIdAndDate(@Param("userId") UUID userId, @Param("date") java.time.LocalDate date); + + /** + * Batch delete activities by IDs. + * More efficient than deleting one by one. + * + * @param ids the list of activity IDs to delete + * @return number of deleted activities + */ + @Modifying + @Query("DELETE FROM Activity a WHERE a.id IN :ids") + int deleteByIdIn(@Param("ids") List ids); } diff --git a/src/main/java/org/operaton/fitpub/service/BatchImportService.java b/src/main/java/org/operaton/fitpub/service/BatchImportService.java index f009b1c..46aaabc 100644 --- a/src/main/java/org/operaton/fitpub/service/BatchImportService.java +++ b/src/main/java/org/operaton/fitpub/service/BatchImportService.java @@ -446,6 +446,137 @@ public class BatchImportService { log.error("Marked batch import job {} as FAILED: {}", jobId, errorMessage); } + /** + * Initiates an async undo of a batch import. + * Validates the job and starts background deletion. + * + * @param jobId the batch import job ID + * @param userId the user ID (for authorization check) + * @throws IllegalArgumentException if job not found or access denied + */ + @Transactional + public void undoBatchImport(UUID jobId, UUID userId) { + log.info("Initiating undo for batch import job {} by user {}", jobId, userId); + + // Get job and verify ownership + BatchImportJob job = batchImportJobRepository.findById(jobId) + .orElseThrow(() -> new IllegalArgumentException("Batch import job not found: " + jobId)); + + if (!job.getUserId().equals(userId)) { + throw new IllegalArgumentException("Access denied: Job does not belong to user"); + } + + // Get all successfully imported activities + List successfulResults = batchImportFileResultRepository + .findByJobIdAndStatus(jobId, BatchImportFileResult.FileStatus.SUCCESS); + + List activityIds = successfulResults.stream() + .map(BatchImportFileResult::getActivityId) + .filter(id -> id != null) + .toList(); + + if (activityIds.isEmpty()) { + log.info("No activities to delete for batch import job {}", jobId); + // Delete the job anyway since there's nothing to undo + batchImportJobRepository.delete(job); + return; + } + + log.info("Scheduling async deletion of {} activities for batch import job {}", activityIds.size(), jobId); + + // Start async deletion + self.undoBatchImportAsync(jobId, userId, activityIds); + } + + /** + * Asynchronously deletes all activities from a batch import and recalculates analytics. + * Phase 1: Delete activities in batches (efficient bulk delete) + * Phase 2: Recalculate analytics + * + * @param jobId the batch import job ID + * @param userId the user ID + * @param activityIds the list of activity IDs to delete + */ + @Async("batchImportExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void undoBatchImportAsync(UUID jobId, UUID userId, List activityIds) { + log.info("Starting async undo for batch import job {} ({} activities)", jobId, activityIds.size()); + + try { + // Phase 1: Delete all activities in batches + // PostgreSQL supports large IN clauses, but we chunk for safety and better logging + final int BATCH_SIZE = 500; + int totalDeleted = 0; + + for (int i = 0; i < activityIds.size(); i += BATCH_SIZE) { + int end = Math.min(i + BATCH_SIZE, activityIds.size()); + List batch = activityIds.subList(i, end); + + log.debug("Deleting batch {}-{} of {} activities", i + 1, end, activityIds.size()); + int deletedInBatch = activityRepository.deleteByIdIn(batch); + totalDeleted += deletedInBatch; + log.debug("Deleted {} activities in batch", deletedInBatch); + } + + log.info("Deleted {} activities for batch import job {}", totalDeleted, jobId); + + // Delete the batch import job and its file results (cascade will handle file results) + batchImportJobRepository.deleteById(jobId); + log.info("Deleted batch import job {}", jobId); + + // Phase 2: Recalculate analytics (in a separate transaction to prevent rollback) + try { + recalculateAnalyticsAfterUndo(userId); + } catch (Exception e) { + log.error("Failed to recalculate analytics after undo (this won't rollback the deletion)", e); + // Don't rethrow - analytics can be recalculated manually + } + + log.info("Batch import job {} undone successfully. Deleted {} activities.", jobId, totalDeleted); + + } catch (Exception e) { + log.error("Failed to undo batch import job {}", jobId, e); + // If deletion fails, the job will remain in the database for retry + } + } + + /** + * Recalculates analytics after undo operation. + * Runs in a separate transaction to prevent rollback of activity deletion. + * Optimized to rebuild analytics once instead of per-activity. + * + * @param userId the user ID + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + protected void recalculateAnalyticsAfterUndo(UUID userId) { + log.info("Recalculating analytics after undo for user {}", userId); + + try { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalStateException("User not found: " + userId)); + + // Rebuild heatmap once (analyzes all activities) + log.debug("Rebuilding user heatmap..."); + heatmapGridService.recalculateUserHeatmap(user); + + // Get remaining activities count for logging + long remainingCount = activityRepository.countByUserId(userId); + log.info("Recalculating analytics for {} remaining activities", remainingCount); + + // Note: Personal records, achievements, training load, and summaries + // are typically calculated on-the-fly or cached. After bulk deletion, + // they will be recalculated when activities are accessed. + // A full rebuild would require iterating through all activities again, + // which we avoid for performance. + + log.info("Analytics recalculation completed after undo (heatmap rebuilt)"); + + } catch (Exception e) { + log.error("Failed to recalculate analytics after undo", e); + // Don't rethrow - analytics can be recalculated manually or on-demand + } + } + /** * Cleans up old batch import jobs older than the specified retention period. * diff --git a/src/main/resources/static/js/batch-import.js b/src/main/resources/static/js/batch-import.js index fcfbdfa..dc700ff 100644 --- a/src/main/resources/static/js/batch-import.js +++ b/src/main/resources/static/js/batch-import.js @@ -430,13 +430,27 @@ `; card.appendChild(stats); - // View details button for completed jobs + // Buttons for completed jobs if (job.status === 'COMPLETED' || job.status === 'FAILED') { + const buttonGroup = document.createElement('div'); + buttonGroup.className = 'mt-2'; + const viewButton = document.createElement('button'); - viewButton.className = 'btn btn-sm btn-outline-primary mt-2'; + viewButton.className = 'btn btn-sm btn-outline-primary me-2'; viewButton.textContent = 'View Details'; viewButton.onclick = () => viewJobDetails(job.id); - card.appendChild(viewButton); + buttonGroup.appendChild(viewButton); + + // Undo button for completed jobs with successful imports + if (job.status === 'COMPLETED' && job.successCount > 0) { + const undoButton = document.createElement('button'); + undoButton.className = 'btn btn-sm btn-outline-danger'; + undoButton.innerHTML = ' Undo Import'; + undoButton.onclick = () => undoBatchImport(job.id, job.filename, job.successCount); + buttonGroup.appendChild(undoButton); + } + + card.appendChild(buttonGroup); } container.appendChild(card); @@ -455,6 +469,65 @@ }); } + /** + * Undo a batch import by deleting all successfully imported activities. + * Shows confirmation dialog before proceeding. + * Deletion happens asynchronously in the background. + */ + async function undoBatchImport(jobId, filename, successCount) { + // Show confirmation dialog + const confirmed = confirm( + `Are you sure you want to undo this batch import?\n\n` + + `File: ${filename}\n` + + `This will delete ${successCount} successfully imported ${successCount === 1 ? 'activity' : 'activities'}.\n\n` + + `This operation cannot be reversed!` + ); + + if (!confirmed) { + return; + } + + try { + const response = await authenticatedFetch(`/api/batch-import/jobs/${jobId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + let errorMessage = 'Failed to undo batch import'; + try { + const error = await response.json(); + errorMessage = error.error || error.message || `Server returned ${response.status}`; + } catch (e) { + errorMessage = `Server returned ${response.status}: ${response.statusText}`; + } + throw new Error(errorMessage); + } + + const result = await response.json(); + + FitPub.showAlert( + result.message || 'Batch import undo initiated. Activities are being deleted in the background.', + 'info' + ); + + // Refresh the recent jobs list after a short delay to allow async deletion to start + setTimeout(() => { + loadRecentJobs(); + }, 2000); + + // Clear progress section if this was the current job + if (currentJobId === jobId) { + document.getElementById('progressSection').classList.remove('active'); + document.getElementById('fileResultsSection').style.display = 'none'; + currentJobId = null; + } + + } catch (error) { + console.error('Failed to undo batch import:', error); + FitPub.showAlert('Failed to undo batch import: ' + error.message, 'danger'); + } + } + /** * Format file size for display. */ diff --git a/src/main/resources/static/js/heatmap.js b/src/main/resources/static/js/heatmap.js index 09c4aa5..4814670 100644 --- a/src/main/resources/static/js/heatmap.js +++ b/src/main/resources/static/js/heatmap.js @@ -128,8 +128,8 @@ function renderHeatmap(data) { // Create heat layer with red color scheme heatLayer = L.heatLayer(heatData, { - radius: 30, // Increased from 25 for better visibility - blur: 20, // Increased from 15 for smoother appearance + radius: 10, // Reduced for more detail + blur: 5, // Reduced for sharper appearance maxZoom: 17, max: 0.8, // Reduced from 1.0 to make colors more intense minOpacity: 0.3, // Minimum opacity for better visibility