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