Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
Expand Down Expand Up @@ -64,6 +69,11 @@
<artifactId>bucket4j_jdk17-hazelcast</artifactId>
<version>8.19.0</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,6 +28,10 @@ public Config hazelcastConfiguration() {

return config;
}
@Bean
public HazelcastInstance hazelcastInstance(Config hazelcastConfiguration) {
return Hazelcast.newHazelcastInstance(hazelcastConfiguration);
}

@Bean
public IMap<String, byte[]> rateLimitMap(
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
4 changes: 4 additions & 0 deletions src/main/java/com/gothsins/resolve/dto/TicketResponseDTO.java
Original file line number Diff line number Diff line change
@@ -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.*;
Expand All @@ -25,4 +26,7 @@ public class TicketResponseDTO {
private LocalDateTime updatedAt;
private LocalDateTime resolvedAt;
private LocalDateTime closedAt;
private LocalDateTime slaDeadline;

private SlaStatus slaStatus;
}
3 changes: 2 additions & 1 deletion src/main/java/com/gothsins/resolve/entity/Category.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
3 changes: 3 additions & 0 deletions src/main/java/com/gothsins/resolve/entity/Ticket.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,7 @@ public class Ticket {

@Column(name = "closed_at")
private LocalDateTime closedAt;

@Column(name = "sla_deadline", nullable = false)
private LocalDateTime slaDeadline;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.gothsins.resolve.entity.enums;

public enum SlaStatus {
ON_TIME,
AT_RISK,
VIOLATED
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,4 +40,11 @@ public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotVal
ErrorResponse error = new ErrorResponse(message, 400);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -78,6 +81,6 @@ public CorsConfigurationSource corsConfigurationSource() {

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return (CorsConfigurationSource) source;
return source;
}
}
69 changes: 69 additions & 0 deletions src/main/java/com/gothsins/resolve/service/MetricsService.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
39 changes: 39 additions & 0 deletions src/main/java/com/gothsins/resolve/service/SlaService.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
15 changes: 15 additions & 0 deletions src/main/java/com/gothsins/resolve/service/TicketService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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) {
Expand All @@ -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);
}
Expand Down Expand Up @@ -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());
Expand All @@ -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);
}
Expand Down Expand Up @@ -142,20 +150,27 @@ private TicketResponseDTO toResponseDTO(Ticket ticket) {
.updatedAt(ticket.getUpdatedAt())
.resolvedAt(ticket.getResolvedAt())
.closedAt(ticket.getClosedAt())
.slaDeadline(ticket.getSlaDeadline())
.slaStatus(slaService.calculateStatus(ticket))
.build();
}

private CategoryResponseDTO toCategoryDTO(Category category) {
return CategoryResponseDTO.builder()
.id(category.getId())
.name(category.getName())
.active(category.getActive())
.build();
}

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();
}
}
3 changes: 2 additions & 1 deletion src/main/java/com/gothsins/resolve/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -31,7 +32,7 @@ public UserResponseDTO create(UserRequestDTO dto) {
.build();

User saved = userRepository.save(user);

metricsService.incrementUserRegistered();
return toResponseDTO(saved);
}

Expand Down
1 change: 1 addition & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading