From 81ab438b72f2ab3c50488164d370cdc36fe0542a Mon Sep 17 00:00:00 2001 From: vimal-tech-starter Date: Fri, 5 Jun 2026 07:18:16 +0530 Subject: [PATCH] feat: operational email retry admin controls Signed-off-by: vimal-tech-starter --- .../contactapi/config/SecurityConfig.java | 3 +- .../admin/EmailRetryAdminController.java | 103 ++++++++++++ .../dto/admin/EmailLogResponse.java | 25 +++ .../contactapi/dto/admin/PagedResponse.java | 21 +++ .../dto/admin/RetryOperationResponse.java | 15 ++ .../dto/admin/RetryStatisticsResponse.java | 15 ++ .../exception/EmailNotFoundException.java | 8 + .../exception/GlobalExceptionHandler.java | 51 ++++-- .../contactapi/mapper/EmailLogMapper.java | 26 +++ .../repository/EmailLogRepository.java | 15 ++ .../service/admin/EmailOperationsService.java | 26 +++ .../admin/EmailOperationsServiceImpl.java | 155 ++++++++++++++++++ src/main/resources/application-dev.yml | 2 +- 13 files changed, 450 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/vimaltech/contactapi/controller/admin/EmailRetryAdminController.java create mode 100644 src/main/java/com/vimaltech/contactapi/dto/admin/EmailLogResponse.java create mode 100644 src/main/java/com/vimaltech/contactapi/dto/admin/PagedResponse.java create mode 100644 src/main/java/com/vimaltech/contactapi/dto/admin/RetryOperationResponse.java create mode 100644 src/main/java/com/vimaltech/contactapi/dto/admin/RetryStatisticsResponse.java create mode 100644 src/main/java/com/vimaltech/contactapi/exception/EmailNotFoundException.java create mode 100644 src/main/java/com/vimaltech/contactapi/mapper/EmailLogMapper.java create mode 100644 src/main/java/com/vimaltech/contactapi/service/admin/EmailOperationsService.java create mode 100644 src/main/java/com/vimaltech/contactapi/service/admin/EmailOperationsServiceImpl.java diff --git a/src/main/java/com/vimaltech/contactapi/config/SecurityConfig.java b/src/main/java/com/vimaltech/contactapi/config/SecurityConfig.java index ced24cc..e9d0b95 100644 --- a/src/main/java/com/vimaltech/contactapi/config/SecurityConfig.java +++ b/src/main/java/com/vimaltech/contactapi/config/SecurityConfig.java @@ -26,7 +26,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.GET, "/api/v1/contacts/options").permitAll() // 🔒 Admin endpoint - .requestMatchers(HttpMethod.GET, "/api/v1/contacts").authenticated() + .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") + .requestMatchers(HttpMethod.GET, "/api/v1/contacts").hasRole("ADMIN") // Everything else .anyRequest().permitAll() diff --git a/src/main/java/com/vimaltech/contactapi/controller/admin/EmailRetryAdminController.java b/src/main/java/com/vimaltech/contactapi/controller/admin/EmailRetryAdminController.java new file mode 100644 index 0000000..6ea0bc6 --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/controller/admin/EmailRetryAdminController.java @@ -0,0 +1,103 @@ +package com.vimaltech.contactapi.controller.admin; + +import com.vimaltech.contactapi.dto.admin.EmailLogResponse; +import com.vimaltech.contactapi.dto.admin.PagedResponse; +import com.vimaltech.contactapi.dto.admin.RetryOperationResponse; +import com.vimaltech.contactapi.dto.admin.RetryStatisticsResponse; +import com.vimaltech.contactapi.enums.EmailStatus; +import com.vimaltech.contactapi.service.admin.EmailOperationsService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/email-retries") +@RequiredArgsConstructor +public class EmailRetryAdminController { + + private final EmailOperationsService emailOperationsService; + + @GetMapping("/stats") + public ResponseEntity getStatistics() { + + return ResponseEntity.ok( + emailOperationsService.getStatistics() + ); + } + + @GetMapping + public ResponseEntity> getEmailLogs( + + @RequestParam(required = false) + EmailStatus status, + + @RequestParam(defaultValue = "0") + int page, + + @RequestParam(defaultValue = "20") + int size + ) { + + Pageable pageable = PageRequest.of(page, size); + + Page emailPage = + emailOperationsService.getEmailLogs( + status, + pageable + ); + + PagedResponse response = + new PagedResponse<>( + emailPage.getContent(), + emailPage.getNumber(), + emailPage.getSize(), + emailPage.getTotalElements(), + emailPage.getTotalPages(), + emailPage.isFirst(), + emailPage.isLast() + ); + + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}") + public ResponseEntity getEmailLog( + @PathVariable Long id + ) { + + return ResponseEntity.ok( + emailOperationsService.getEmailLog(id) + ); + } + + @PostMapping("/{id}/retry") + public ResponseEntity retryEmail( + @PathVariable Long id + ) { + + return ResponseEntity.ok( + emailOperationsService.retryEmail(id) + ); + } + + @PostMapping("/retry-all") + public ResponseEntity retryAllFailedEmails() { + + return ResponseEntity.ok( + emailOperationsService.retryAllFailedEmails() + ); + } + + @PostMapping("/{id}/reset") + public ResponseEntity resetPermanentFailure( + @PathVariable Long id + ) { + + return ResponseEntity.ok( + emailOperationsService.resetPermanentFailure(id) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/dto/admin/EmailLogResponse.java b/src/main/java/com/vimaltech/contactapi/dto/admin/EmailLogResponse.java new file mode 100644 index 0000000..2663b95 --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/dto/admin/EmailLogResponse.java @@ -0,0 +1,25 @@ +package com.vimaltech.contactapi.dto.admin; + +import com.vimaltech.contactapi.enums.EmailStatus; + +import java.time.LocalDateTime; + +public record EmailLogResponse( + + Long id, + + String toEmail, + + String subject, + + EmailStatus status, + + int retryCount, + + String lastError, + + LocalDateTime createdAt, + + LocalDateTime lastAttemptAt +) { +} \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/dto/admin/PagedResponse.java b/src/main/java/com/vimaltech/contactapi/dto/admin/PagedResponse.java new file mode 100644 index 0000000..4d50c8f --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/dto/admin/PagedResponse.java @@ -0,0 +1,21 @@ +package com.vimaltech.contactapi.dto.admin; + +import java.util.List; + +public record PagedResponse( + + List content, + + int page, + + int size, + + long totalElements, + + int totalPages, + + boolean first, + + boolean last +) { +} \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/dto/admin/RetryOperationResponse.java b/src/main/java/com/vimaltech/contactapi/dto/admin/RetryOperationResponse.java new file mode 100644 index 0000000..7d48a5b --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/dto/admin/RetryOperationResponse.java @@ -0,0 +1,15 @@ +package com.vimaltech.contactapi.dto.admin; + +import java.time.LocalDateTime; + +public record RetryOperationResponse( + + boolean success, + + String message, + + Long emailId, + + LocalDateTime timestamp +) { +} \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/dto/admin/RetryStatisticsResponse.java b/src/main/java/com/vimaltech/contactapi/dto/admin/RetryStatisticsResponse.java new file mode 100644 index 0000000..0b6fe3b --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/dto/admin/RetryStatisticsResponse.java @@ -0,0 +1,15 @@ +package com.vimaltech.contactapi.dto.admin; + +public record RetryStatisticsResponse( + + long pending, + + long inProgress, + + long sent, + + long failed, + + long failedPermanent +) { +} \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/exception/EmailNotFoundException.java b/src/main/java/com/vimaltech/contactapi/exception/EmailNotFoundException.java new file mode 100644 index 0000000..29d225b --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/exception/EmailNotFoundException.java @@ -0,0 +1,8 @@ +package com.vimaltech.contactapi.exception; + +public class EmailNotFoundException extends RuntimeException { + + public EmailNotFoundException(Long id) { + super("Email log not found with id: " + id); + } +} \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/exception/GlobalExceptionHandler.java b/src/main/java/com/vimaltech/contactapi/exception/GlobalExceptionHandler.java index 606ed00..1719ab8 100644 --- a/src/main/java/com/vimaltech/contactapi/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/vimaltech/contactapi/exception/GlobalExceptionHandler.java @@ -7,14 +7,16 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; import java.time.LocalDateTime; @RestControllerAdvice public class GlobalExceptionHandler { - private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + private static final Logger log = + LoggerFactory.getLogger(GlobalExceptionHandler.class); // ✅ 1. Bean validation errors (@Valid) @ExceptionHandler(MethodArgumentNotValidException.class) @@ -39,22 +41,25 @@ public ResponseEntity handleValidationException( return ResponseEntity.badRequest().body(response); } - // ✅ 2. JSON parsing / ENUM errors (UPDATED - precise handling) + // ✅ 2. JSON parsing / ENUM errors @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity handleJsonParseError(HttpMessageNotReadableException ex) { + public ResponseEntity handleJsonParseError( + HttpMessageNotReadableException ex) { - log.warn("Invalid request format or enum value: {}", ex.getMessage()); + log.warn( + "Invalid request format or enum value: {}", + ex.getMessage() + ); String message = "Invalid request format"; - // 🔥 Extract root cause (THIS is what you asked about) Throwable root = ex.getCause(); - if (root instanceof IllegalArgumentException && - root.getMessage() != null && - root.getMessage().contains("Invalid subject value")) { + if (root instanceof IllegalArgumentException + && root.getMessage() != null + && root.getMessage().contains("Invalid subject value")) { - message = root.getMessage(); // use exact enum error + message = root.getMessage(); } ApiResponse response = new ApiResponse( @@ -66,9 +71,27 @@ public ResponseEntity handleJsonParseError(HttpMessageNotReadableEx return ResponseEntity.badRequest().body(response); } - // ✅ 3. Catch-all (fallback) + // ✅ 3. Email log not found + @ExceptionHandler(EmailNotFoundException.class) + public ResponseEntity handleEmailNotFoundException( + EmailNotFoundException ex) { + + log.warn("Email log not found: {}", ex.getMessage()); + + ApiResponse response = new ApiResponse( + false, + ex.getMessage(), + LocalDateTime.now() + ); + + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(response); + } + + // ✅ 4. Catch-all (fallback) @ExceptionHandler(Exception.class) - public ResponseEntity handleGenericException(Exception ex) { + public ResponseEntity handleGenericException( + Exception ex) { log.error("Unhandled exception occurred", ex); @@ -78,6 +101,8 @@ public ResponseEntity handleGenericException(Exception ex) { LocalDateTime.now() ); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + return ResponseEntity.status( + HttpStatus.INTERNAL_SERVER_ERROR + ).body(response); } } \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/mapper/EmailLogMapper.java b/src/main/java/com/vimaltech/contactapi/mapper/EmailLogMapper.java new file mode 100644 index 0000000..d084838 --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/mapper/EmailLogMapper.java @@ -0,0 +1,26 @@ +package com.vimaltech.contactapi.mapper; + +import com.vimaltech.contactapi.dto.admin.EmailLogResponse; +import com.vimaltech.contactapi.entity.EmailLog; + +public final class EmailLogMapper { + + private EmailLogMapper() { + } + + public static EmailLogResponse toResponse( + EmailLog emailLog + ) { + + return new EmailLogResponse( + emailLog.getId(), + emailLog.getToEmail(), + emailLog.getSubject(), + emailLog.getStatus(), + emailLog.getRetryCount(), + emailLog.getLastError(), + emailLog.getCreatedAt(), + emailLog.getLastAttemptAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/repository/EmailLogRepository.java b/src/main/java/com/vimaltech/contactapi/repository/EmailLogRepository.java index b5bd63b..5558a68 100644 --- a/src/main/java/com/vimaltech/contactapi/repository/EmailLogRepository.java +++ b/src/main/java/com/vimaltech/contactapi/repository/EmailLogRepository.java @@ -2,6 +2,8 @@ import com.vimaltech.contactapi.entity.EmailLog; import com.vimaltech.contactapi.enums.EmailStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -14,4 +16,17 @@ public interface EmailLogRepository List findTop10ByStatusOrderByCreatedAtAsc( EmailStatus status ); + + Page findByStatus( + EmailStatus status, + Pageable pageable + ); + + List findByStatus( + EmailStatus status + ); + + long countByStatus( + EmailStatus status + ); } \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/service/admin/EmailOperationsService.java b/src/main/java/com/vimaltech/contactapi/service/admin/EmailOperationsService.java new file mode 100644 index 0000000..6f0b38c --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/service/admin/EmailOperationsService.java @@ -0,0 +1,26 @@ +package com.vimaltech.contactapi.service.admin; + +import com.vimaltech.contactapi.dto.admin.EmailLogResponse; +import com.vimaltech.contactapi.dto.admin.RetryOperationResponse; +import com.vimaltech.contactapi.dto.admin.RetryStatisticsResponse; +import com.vimaltech.contactapi.enums.EmailStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface EmailOperationsService { + + RetryStatisticsResponse getStatistics(); + + Page getEmailLogs( + EmailStatus status, + Pageable pageable + ); + + EmailLogResponse getEmailLog(Long id); + + RetryOperationResponse retryEmail(Long id); + + RetryOperationResponse retryAllFailedEmails(); + + RetryOperationResponse resetPermanentFailure(Long id); +} \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/service/admin/EmailOperationsServiceImpl.java b/src/main/java/com/vimaltech/contactapi/service/admin/EmailOperationsServiceImpl.java new file mode 100644 index 0000000..b6d91ec --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/service/admin/EmailOperationsServiceImpl.java @@ -0,0 +1,155 @@ +package com.vimaltech.contactapi.service.admin; + +import com.vimaltech.contactapi.dto.admin.EmailLogResponse; +import com.vimaltech.contactapi.dto.admin.RetryOperationResponse; +import com.vimaltech.contactapi.dto.admin.RetryStatisticsResponse; +import com.vimaltech.contactapi.entity.EmailLog; +import com.vimaltech.contactapi.enums.EmailStatus; +import com.vimaltech.contactapi.exception.EmailNotFoundException; +import com.vimaltech.contactapi.mapper.EmailLogMapper; +import com.vimaltech.contactapi.repository.EmailLogRepository; +import com.vimaltech.contactapi.service.EmailService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class EmailOperationsServiceImpl + implements EmailOperationsService { + + private final EmailLogRepository emailLogRepository; + private final EmailService emailService; + + @Override + public RetryStatisticsResponse getStatistics() { + + return new RetryStatisticsResponse( + emailLogRepository.countByStatus(EmailStatus.PENDING), + emailLogRepository.countByStatus(EmailStatus.IN_PROGRESS), + emailLogRepository.countByStatus(EmailStatus.SENT), + emailLogRepository.countByStatus(EmailStatus.FAILED), + emailLogRepository.countByStatus(EmailStatus.FAILED_PERMANENT) + ); + } + + @Override + public Page getEmailLogs( + EmailStatus status, + Pageable pageable + ) { + + if (status == null) { + + return emailLogRepository.findAll(pageable) + .map(EmailLogMapper::toResponse); + } + + return emailLogRepository.findByStatus( + status, + pageable + ) + .map(EmailLogMapper::toResponse); + } + + @Override + public EmailLogResponse getEmailLog(Long id) { + + EmailLog emailLog = emailLogRepository + .findById(id) + .orElseThrow(() -> + new EmailNotFoundException(id)); + + return EmailLogMapper.toResponse(emailLog); + } + + @Override + public RetryOperationResponse retryEmail(Long id) { + + EmailLog emailLog = emailLogRepository + .findById(id) + .orElseThrow(() -> + new EmailNotFoundException(id)); + + // Prevent duplicate resend + if (emailLog.getStatus() == EmailStatus.SENT) { + + return new RetryOperationResponse( + false, + "Email already sent", + id, + LocalDateTime.now() + ); + } + + // Prevent retry while already processing + if (emailLog.getStatus() == EmailStatus.IN_PROGRESS) { + + return new RetryOperationResponse( + false, + "Email is currently being processed", + id, + LocalDateTime.now() + ); + } + + emailService.retryEmail(emailLog); + + return new RetryOperationResponse( + true, + "Email retry triggered successfully", + id, + LocalDateTime.now() + ); + } + + @Override + public RetryOperationResponse retryAllFailedEmails() { + + List failedEmails = + emailLogRepository.findByStatus( + EmailStatus.FAILED + ); + + failedEmails.forEach( + emailService::retryEmail + ); + + return new RetryOperationResponse( + true, + "Retry triggered for " + + failedEmails.size() + + " failed email(s)", + null, + LocalDateTime.now() + ); + } + + @Override + public RetryOperationResponse resetPermanentFailure( + Long id + ) { + + EmailLog emailLog = emailLogRepository + .findById(id) + .orElseThrow(() -> + new EmailNotFoundException(id)); + + emailLog.setRetryCount(0); + emailLog.setLastError(null); + emailLog.setStatus(EmailStatus.FAILED); + + emailLogRepository.save(emailLog); + + return new RetryOperationResponse( + true, + "Email retry state reset successfully", + id, + LocalDateTime.now() + ); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f8574a7..b496c43 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -7,7 +7,7 @@ spring: jpa: hibernate: - ddl-auto: update + ddl-auto: validate properties: hibernate: jdbc: