Undo für Batch Import
This commit is contained in:
parent
a19d4870f7
commit
3b18e30cee
5 changed files with 268 additions and 5 deletions
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue