Undo für Batch Import

This commit is contained in:
Tim Zöller 2026-01-03 14:57:34 +01:00
parent a19d4870f7
commit 3b18e30cee
5 changed files with 268 additions and 5 deletions

View file

@ -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) {
}
}

View file

@ -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<Activity, UUID> {
"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<UUID> ids);
}

View file

@ -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<BatchImportFileResult> successfulResults = batchImportFileResultRepository
.findByJobIdAndStatus(jobId, BatchImportFileResult.FileStatus.SUCCESS);
List<UUID> 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<UUID> 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<UUID> 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.
*

View file

@ -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 = '<i class="bi bi-arrow-counterclockwise"></i> 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.
*/

View file

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