diff --git a/pom.xml b/pom.xml index 39060d1..2f3b6c0 100644 --- a/pom.xml +++ b/pom.xml @@ -34,6 +34,11 @@ org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-actuator + + org.springframework.boot spring-boot-starter-security @@ -64,6 +69,11 @@ bucket4j_jdk17-hazelcast 8.19.0 + + io.micrometer + micrometer-registry-prometheus + runtime + com.bucket4j bucket4j_jdk17-core diff --git a/src/main/java/com/gothsins/resolve/config/HazelcastConfig.java b/src/main/java/com/gothsins/resolve/config/HazelcastConfig.java index cb225df..03f7b9d 100644 --- a/src/main/java/com/gothsins/resolve/config/HazelcastConfig.java +++ b/src/main/java/com/gothsins/resolve/config/HazelcastConfig.java @@ -10,6 +10,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import com.hazelcast.core.Hazelcast; @Configuration public class HazelcastConfig { @@ -27,6 +28,10 @@ public Config hazelcastConfiguration() { return config; } + @Bean + public HazelcastInstance hazelcastInstance(Config hazelcastConfiguration) { + return Hazelcast.newHazelcastInstance(hazelcastConfiguration); + } @Bean public IMap rateLimitMap( diff --git a/src/main/java/com/gothsins/resolve/config/HazelcastHealthIndicator.java b/src/main/java/com/gothsins/resolve/config/HazelcastHealthIndicator.java new file mode 100644 index 0000000..7060533 --- /dev/null +++ b/src/main/java/com/gothsins/resolve/config/HazelcastHealthIndicator.java @@ -0,0 +1,25 @@ +package com.gothsins.resolve.config; + +import com.hazelcast.core.HazelcastInstance; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.Health; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class HazelcastHealthIndicator implements HealthIndicator { + + private final HazelcastInstance hazelcastInstance; + + @Override + public Health health() { + if (hazelcastInstance.getLifecycleService().isRunning()) { + return Health.up() + .withDetail("cluster", hazelcastInstance.getConfig().getClusterName()) + .withDetail("members", hazelcastInstance.getCluster().getMembers().size()) + .build(); + } + return Health.down().build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/gothsins/resolve/dto/TicketResponseDTO.java b/src/main/java/com/gothsins/resolve/dto/TicketResponseDTO.java index 77b4fdf..a7d0654 100644 --- a/src/main/java/com/gothsins/resolve/dto/TicketResponseDTO.java +++ b/src/main/java/com/gothsins/resolve/dto/TicketResponseDTO.java @@ -1,4 +1,5 @@ package com.gothsins.resolve.dto; +import com.gothsins.resolve.entity.enums.SlaStatus; import com.gothsins.resolve.entity.enums.TicketPriority; import com.gothsins.resolve.entity.enums.TicketStatus; import lombok.*; @@ -25,4 +26,7 @@ public class TicketResponseDTO { private LocalDateTime updatedAt; private LocalDateTime resolvedAt; private LocalDateTime closedAt; + private LocalDateTime slaDeadline; + + private SlaStatus slaStatus; } diff --git a/src/main/java/com/gothsins/resolve/entity/Category.java b/src/main/java/com/gothsins/resolve/entity/Category.java index cd45434..4e22340 100644 --- a/src/main/java/com/gothsins/resolve/entity/Category.java +++ b/src/main/java/com/gothsins/resolve/entity/Category.java @@ -21,6 +21,7 @@ public class Category { @Column(nullable = false, unique = true) private String name; + @Builder.Default @Column(nullable = false) - private Boolean active; + private Boolean active = true; } \ No newline at end of file diff --git a/src/main/java/com/gothsins/resolve/entity/Ticket.java b/src/main/java/com/gothsins/resolve/entity/Ticket.java index ee34518..c02aae7 100644 --- a/src/main/java/com/gothsins/resolve/entity/Ticket.java +++ b/src/main/java/com/gothsins/resolve/entity/Ticket.java @@ -61,4 +61,7 @@ public class Ticket { @Column(name = "closed_at") private LocalDateTime closedAt; + + @Column(name = "sla_deadline", nullable = false) + private LocalDateTime slaDeadline; } \ No newline at end of file diff --git a/src/main/java/com/gothsins/resolve/entity/enums/SlaStatus.java b/src/main/java/com/gothsins/resolve/entity/enums/SlaStatus.java new file mode 100644 index 0000000..48472c4 --- /dev/null +++ b/src/main/java/com/gothsins/resolve/entity/enums/SlaStatus.java @@ -0,0 +1,7 @@ +package com.gothsins.resolve.entity.enums; + +public enum SlaStatus { + ON_TIME, + AT_RISK, + VIOLATED +} \ No newline at end of file diff --git a/src/main/java/com/gothsins/resolve/entity/enums/TicketPriority.java b/src/main/java/com/gothsins/resolve/entity/enums/TicketPriority.java index 2e558ea..7fd4395 100644 --- a/src/main/java/com/gothsins/resolve/entity/enums/TicketPriority.java +++ b/src/main/java/com/gothsins/resolve/entity/enums/TicketPriority.java @@ -3,11 +3,23 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; +import java.time.Duration; + public enum TicketPriority { - LOW, - MEDIUM, - HIGH, - CRITICAL; + LOW(Duration.ofHours(72)), + MEDIUM(Duration.ofHours(48)), + HIGH(Duration.ofHours(24)), + CRITICAL(Duration.ofHours(12)); + + private final Duration slaDuration; + + TicketPriority(Duration slaDuration) { + this.slaDuration = slaDuration; + } + + public Duration getSlaDuration() { + return slaDuration; + } @JsonValue public String toValue() { diff --git a/src/main/java/com/gothsins/resolve/exception/GlobalExceptionHandler.java b/src/main/java/com/gothsins/resolve/exception/GlobalExceptionHandler.java index 385e898..9b2a6e3 100644 --- a/src/main/java/com/gothsins/resolve/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/gothsins/resolve/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -39,4 +40,11 @@ public ResponseEntity handleValidationErrors(MethodArgumentNotVal ErrorResponse error = new ErrorResponse(message, 400); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); } + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleMalformedRequest(HttpMessageNotReadableException ex) { + ErrorResponse error = new ErrorResponse( + "Corpo da requisição inválido ou ausente — confira o formato e os valores enviados (ex: prioridade deve ser LOW, MEDIUM, HIGH ou CRITICAL)", + HttpStatus.BAD_REQUEST.value()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + } } diff --git a/src/main/java/com/gothsins/resolve/security/SecurityConfig.java b/src/main/java/com/gothsins/resolve/security/SecurityConfig.java index 21e41b5..bff36ad 100644 --- a/src/main/java/com/gothsins/resolve/security/SecurityConfig.java +++ b/src/main/java/com/gothsins/resolve/security/SecurityConfig.java @@ -17,7 +17,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.List; @Configuration @@ -59,6 +59,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .exceptionHandling(ex -> ex.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/actuator/health").permitAll() + .requestMatchers("/actuator/prometheus").permitAll() + .requestMatchers("/api/auth/**", "/error").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); @@ -78,6 +81,6 @@ public CorsConfigurationSource corsConfigurationSource() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); - return (CorsConfigurationSource) source; + return source; } } \ No newline at end of file diff --git a/src/main/java/com/gothsins/resolve/service/MetricsService.java b/src/main/java/com/gothsins/resolve/service/MetricsService.java new file mode 100644 index 0000000..84a4372 --- /dev/null +++ b/src/main/java/com/gothsins/resolve/service/MetricsService.java @@ -0,0 +1,69 @@ +package com.gothsins.resolve.service; + +import com.gothsins.resolve.entity.enums.SlaStatus; +import com.gothsins.resolve.entity.enums.TicketPriority; +import com.gothsins.resolve.repository.TicketRepository; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +public class MetricsService { + + private final MeterRegistry meterRegistry; + private final TicketRepository ticketRepository; + private final SlaService slaService; + + public MetricsService(MeterRegistry meterRegistry, TicketRepository ticketRepository, SlaService slaService) { + this.meterRegistry = meterRegistry; + this.ticketRepository = ticketRepository; + this.slaService = slaService; + + Gauge.builder("tickets.sla.at_risk", this, MetricsService::countAtRiskTickets) + .description("Tickets abertos em risco de violar o SLA") + .register(meterRegistry); + + Gauge.builder("tickets.sla.violated", this, MetricsService::countViolatedTickets) + .description("Tickets abertos que já violaram o SLA") + .register(meterRegistry); + } + + public void recordResolutionTime(TicketPriority priority, Duration duration) { + Timer.builder("tickets.resolution.time") + .description("Tempo entre criação e resolução do ticket") + .tag("priority", priority.name()) + .register(meterRegistry) + .record(duration); + } + + public void incrementTicketCreated(TicketPriority priority) { + meterRegistry.counter("tickets.created", "priority", priority.name()).increment(); + } + + public void incrementTicketStatusChanged(String oldStatus, String newStatus) { + meterRegistry.counter("tickets.status.changed", + "from", oldStatus, "to", newStatus).increment(); + } + + public void incrementUserRegistered() { + meterRegistry.counter("users.registered").increment(); + } + + private double countAtRiskTickets() { + return countOpenTicketsByStatus(SlaStatus.AT_RISK); + } + + private double countViolatedTickets() { + return countOpenTicketsByStatus(SlaStatus.VIOLATED); + } + + private long countOpenTicketsByStatus(SlaStatus targetStatus) { + return ticketRepository.findAll().stream() + .filter(t -> t.getResolvedAt() == null && t.getClosedAt() == null) + .filter(t -> slaService.calculateStatus(t) == targetStatus) + .count(); + } +} \ No newline at end of file diff --git a/src/main/java/com/gothsins/resolve/service/SlaService.java b/src/main/java/com/gothsins/resolve/service/SlaService.java new file mode 100644 index 0000000..9cc8c56 --- /dev/null +++ b/src/main/java/com/gothsins/resolve/service/SlaService.java @@ -0,0 +1,39 @@ +package com.gothsins.resolve.service; + +import com.gothsins.resolve.entity.Ticket; +import com.gothsins.resolve.entity.enums.SlaStatus; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; + +@Service +public class SlaService { + + private static final double AT_RISK_THRESHOLD = 0.2; + + public SlaStatus calculateStatus(Ticket ticket) { + LocalDateTime referenceTime = resolveReferenceTime(ticket); + + if (referenceTime.isAfter(ticket.getSlaDeadline())) { + return SlaStatus.VIOLATED; + } + + Duration total = ticket.getPriority().getSlaDuration(); + Duration remaining = Duration.between(referenceTime, ticket.getSlaDeadline()); + + double remainingRatio = (double) remaining.toMinutes() / total.toMinutes(); + + return remainingRatio <= AT_RISK_THRESHOLD ? SlaStatus.AT_RISK : SlaStatus.ON_TIME; + } + + private LocalDateTime resolveReferenceTime(Ticket ticket) { + if (ticket.getResolvedAt() != null) { + return ticket.getResolvedAt(); + } + if (ticket.getClosedAt() != null) { + return ticket.getClosedAt(); + } + return LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/gothsins/resolve/service/TicketService.java b/src/main/java/com/gothsins/resolve/service/TicketService.java index 69b7ad9..b6be8f6 100644 --- a/src/main/java/com/gothsins/resolve/service/TicketService.java +++ b/src/main/java/com/gothsins/resolve/service/TicketService.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; import java.time.LocalDateTime; import java.util.List; @@ -24,6 +25,8 @@ public class TicketService { private final UserRepository userRepository; private final CategoryRepository categoryRepository; private final TicketHistoryService ticketHistoryService; + private final MetricsService metricsService; + private final SlaService slaService; @Transactional public TicketResponseDTO create(TicketRequestDTO dto) { @@ -49,9 +52,11 @@ public TicketResponseDTO create(TicketRequestDTO dto) { .category(category) .requester(requester) .assignedAgent(assignedAgent) + .slaDeadline(LocalDateTime.now().plus(dto.getPriority().getSlaDuration())) .build(); Ticket saved = ticketRepository.save(ticket); + metricsService.incrementTicketCreated(saved.getPriority()); return toResponseDTO(saved); } @@ -100,6 +105,8 @@ public TicketResponseDTO changeStatus(Long id, ChangeStatusDTO dto) { if (newStatus == TicketStatus.RESOLVED) { ticket.setResolvedAt(LocalDateTime.now()); + Duration resolutionTime = Duration.between(ticket.getCreatedAt(), ticket.getResolvedAt()); + metricsService.recordResolutionTime(ticket.getPriority(), resolutionTime); } if (newStatus == TicketStatus.CLOSED) { ticket.setClosedAt(LocalDateTime.now()); @@ -109,6 +116,7 @@ public TicketResponseDTO changeStatus(Long id, ChangeStatusDTO dto) { ticketHistoryService.registerChange( updated, user, "STATUS_CHANGE", oldStatus.name(), newStatus.name()); + metricsService.incrementTicketStatusChanged(oldStatus.name(), newStatus.name()); return toResponseDTO(updated); } @@ -142,6 +150,8 @@ private TicketResponseDTO toResponseDTO(Ticket ticket) { .updatedAt(ticket.getUpdatedAt()) .resolvedAt(ticket.getResolvedAt()) .closedAt(ticket.getClosedAt()) + .slaDeadline(ticket.getSlaDeadline()) + .slaStatus(slaService.calculateStatus(ticket)) .build(); } @@ -149,6 +159,7 @@ private CategoryResponseDTO toCategoryDTO(Category category) { return CategoryResponseDTO.builder() .id(category.getId()) .name(category.getName()) + .active(category.getActive()) .build(); } @@ -156,6 +167,10 @@ private UserResponseDTO toUserDTO(User user) { return UserResponseDTO.builder() .id(user.getId()) .name(user.getName()) + .email(user.getEmail()) + .role(user.getRole()) + .active(user.getActive()) + .createdAt(user.getCreatedAt()) .build(); } } \ No newline at end of file diff --git a/src/main/java/com/gothsins/resolve/service/UserService.java b/src/main/java/com/gothsins/resolve/service/UserService.java index c1cdf4a..63160d6 100644 --- a/src/main/java/com/gothsins/resolve/service/UserService.java +++ b/src/main/java/com/gothsins/resolve/service/UserService.java @@ -21,6 +21,7 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + private final MetricsService metricsService; @Transactional public UserResponseDTO create(UserRequestDTO dto) { @@ -31,7 +32,7 @@ public UserResponseDTO create(UserRequestDTO dto) { .build(); User saved = userRepository.save(user); - + metricsService.incrementUserRegistered(); return toResponseDTO(saved); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 87e56ed..6aba255 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,3 +9,4 @@ spring.jpa.show-sql=false jwt.secret=${JWT_SECRET} jwt.expiration=86400000 logging.level.org.springframework.security=DEBUG +management.endpoints.web.exposure.include=health,info,prometheus \ No newline at end of file diff --git a/src/test/java/com/gothsins/resolve/UserServiceTest.java b/src/test/java/com/gothsins/resolve/UserServiceTest.java new file mode 100644 index 0000000..ffe6376 --- /dev/null +++ b/src/test/java/com/gothsins/resolve/UserServiceTest.java @@ -0,0 +1,65 @@ +package com.gothsins.resolve; + +import com.gothsins.resolve.dto.UserResponseDTO; +import com.gothsins.resolve.entity.User; +import com.gothsins.resolve.exception.ResourceNotFoundException; +import com.gothsins.resolve.repository.UserRepository; +import com.gothsins.resolve.service.UserService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserService userService; + + @Test + void testGetUserById() { + Long userId = 1L; + + User user = new User(); + user.setId(userId); + user.setName("Guiverme"); + user.setEmail("guiverme@email.com"); + + when(userRepository.findById(userId)) + .thenReturn(Optional.of(user)); + + UserResponseDTO result = userService.findById(userId); + + assertNotNull(result); + assertEquals(userId, result.getId()); + assertEquals("Guiverme", result.getName()); + assertEquals("guiverme@email.com", result.getEmail()); + + verify(userRepository).findById(userId); + } + + @Test + void shouldThrowExceptionWhenUserDoesNotExist() { + Long userId = 999L; + + when(userRepository.findById(userId)) + .thenReturn(Optional.empty()); + + assertThrows( + ResourceNotFoundException.class, + () -> userService.findById(userId) + ); + + verify(userRepository).findById(userId); + } +} \ No newline at end of file