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
Original file line number Diff line number Diff line change
@@ -1,16 +1,64 @@
package lv.ctco.springboottemplate.exception;

import jakarta.validation.ConstraintViolationException;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;

@RestControllerAdvice
public class GlobalExceptionHandler {

private final MessageSource messageSource;

public GlobalExceptionHandler(MessageSource messageSource) {
this.messageSource = messageSource;
}

@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<String> handleResponseStatusException(ResponseStatusException ex) {
HttpStatus status = HttpStatus.resolve(ex.getStatusCode().value());
HttpStatus effectiveStatus = status != null ? status : HttpStatus.INTERNAL_SERVER_ERROR;
String reason = ex.getReason() != null ? ex.getReason() : message("errors.request.failed");

return ResponseEntity.status(effectiveStatus).body(reason);
}

@ExceptionHandler(BindException.class)
public ResponseEntity<String> handleBindException(BindException ex) {
String message =
ex.getBindingResult().getAllErrors().stream()
.findFirst()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.orElse(message("errors.invalid.request.parameters"));

return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(message);
}

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<String> handleConstraintViolationException(
ConstraintViolationException ex) {
String message =
ex.getConstraintViolations().stream()
.findFirst()
.map(violation -> violation.getMessage())
.orElse(message("errors.invalid.request.parameters"));

return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(message);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleException(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Oops, something went wrong, try later");
.body(message("errors.unexpected"));
}

private String message(String code, Object... args) {
return messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package lv.ctco.springboottemplate.features.statistics;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.time.LocalDate;
import lv.ctco.springboottemplate.features.statistics.models.StatisticsDetailedDto;
import lv.ctco.springboottemplate.features.statistics.models.StatisticsRequestDto;
import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/statistics")
@Tag(name = "Statistics Controller", description = "Todo statistics endpoints")
public class StatisticsController {

private final StatisticsService statisticsService;

public StatisticsController(StatisticsService statisticsService) {
this.statisticsService = statisticsService;
}

@GetMapping
@Operation(summary = "Get todo statistics")
public ResponseEntity<?> getStatistics(@Valid @ModelAttribute StatisticsRequestDto input) {
LocalDate fromDate = input.fromDate();
LocalDate toDate = input.toDate();

if ("summary".equalsIgnoreCase(input.normalizedFormat())) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to refactor this field to enum

StatisticsSummaryDto summary = statisticsService.getSummary(fromDate, toDate);
return ResponseEntity.ok(summary);
}

StatisticsDetailedDto detailed = statisticsService.getDetailed(fromDate, toDate);
return ResponseEntity.ok(detailed);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package lv.ctco.springboottemplate.features.statistics;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lv.ctco.springboottemplate.features.statistics.models.StatisticsDetailedDto;
import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto;
import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodoItemDto;
import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodosDto;
import lv.ctco.springboottemplate.features.todo.Todo;
import org.bson.Document;
import org.springframework.data.mongodb.core.aggregation.AggregationResults;
import org.springframework.stereotype.Service;

@Service
public class StatisticsMapperService {

public StatisticsSummaryDto toSummary(AggregationResults<Document> results) {
long total = 0L;
long completed = 0L;
Map<String, Long> userStats = new HashMap<>();

for (Document doc : results) {
AggregatedRow row = toAggregatedRow(doc);

total += row.count();
if (row.completed()) {
completed += row.count();
}
userStats.merge(row.createdBy(), row.count(), Long::sum);
}

long pending = total - completed;
return new StatisticsSummaryDto(total, completed, pending, userStats);
}

public StatisticsDetailedDto toDetailed(
StatisticsSummaryDto summary, StatisticsTodosDto todosDto) {
return new StatisticsDetailedDto(
summary.totalTodos(),
summary.completedTodos(),
summary.pendingTodos(),
summary.userStats(),
todosDto);
}

public StatisticsTodosDto toTodosDto(List<Todo> todos) {
List<StatisticsTodoItemDto> completedTodos =
todos.stream()
.filter(Todo::completed)
.map(
todo ->
new StatisticsTodoItemDto(
todo.id(),
todo.title(),
todo.createdBy(),
todo.createdAt(),
todo.updatedAt()))
.toList();

List<StatisticsTodoItemDto> pendingTodos =
todos.stream()
.filter(todo -> !todo.completed())
.map(
todo ->
new StatisticsTodoItemDto(
todo.id(), todo.title(), todo.createdBy(), todo.createdAt(), null))
.toList();

return new StatisticsTodosDto(completedTodos, pendingTodos);
}

private AggregatedRow toAggregatedRow(Document doc) {
Document id = (Document) doc.get("_id");
String createdBy = id.getString("createdBy");
boolean isCompleted = Boolean.TRUE.equals(id.getBoolean("completed"));
Number countNumber = doc.get("count", Number.class);
long count = countNumber != null ? countNumber.longValue() : 0L;

return new AggregatedRow(createdBy, isCompleted, count);
}

private record AggregatedRow(String createdBy, boolean completed, long count) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package lv.ctco.springboottemplate.features.statistics;

import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
import lv.ctco.springboottemplate.features.statistics.models.StatisticsDetailedDto;
import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto;
import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodosDto;
import lv.ctco.springboottemplate.features.todo.Todo;
import lv.ctco.springboottemplate.features.todo.TodoService;
import org.bson.Document;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.aggregation.Aggregation;
import org.springframework.data.mongodb.core.aggregation.AggregationResults;
import org.springframework.data.mongodb.core.aggregation.GroupOperation;
import org.springframework.data.mongodb.core.aggregation.MatchOperation;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.stereotype.Service;

@Service
public class StatisticsService {

private final TodoService todoService;
private final MongoTemplate mongoTemplate;
private final StatisticsMapperService statisticsMapperService;

public StatisticsService(
TodoService todoService,
MongoTemplate mongoTemplate,
StatisticsMapperService statisticsMapperService) {
this.todoService = todoService;
this.mongoTemplate = mongoTemplate;
this.statisticsMapperService = statisticsMapperService;
}

public StatisticsSummaryDto getSummary(LocalDate from, LocalDate to) {
return aggregateSummary(from, to);
}

public StatisticsDetailedDto getDetailed(LocalDate from, LocalDate to) {
StatisticsSummaryDto summary = aggregateSummary(from, to);
List<Todo> todos = findTodosInRange(from, to);
StatisticsTodosDto todosDto = statisticsMapperService.toTodosDto(todos);
return statisticsMapperService.toDetailed(summary, todosDto);
}

private StatisticsSummaryDto aggregateSummary(LocalDate from, LocalDate to) {
Aggregation aggregation = buildAggregation(from, to);
AggregationResults<Document> results =
mongoTemplate.aggregate(aggregation, "todos", Document.class);
return statisticsMapperService.toSummary(results);
}

private Aggregation buildAggregation(LocalDate from, LocalDate to) {
Criteria dateCriteria = buildDateCriteria(from, to);

GroupOperation groupByUserAndCompletion =
Aggregation.group("createdBy", "completed").count().as("count");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually we define such string as constants. May be even in dedicated file if there are many related constants


if (dateCriteria != null) {
MatchOperation match = Aggregation.match(dateCriteria);
return Aggregation.newAggregation(match, groupByUserAndCompletion);
}

return Aggregation.newAggregation(groupByUserAndCompletion);
}

private List<Todo> findTodosInRange(LocalDate from, LocalDate to) {
Instant fromInstant = from != null ? atStartOfDay(from) : null;
Instant toInstant = to != null ? atEndOfDay(to) : null;

return todoService.getTodosByCreatedAtRange(fromInstant, toInstant);
}

private Criteria buildDateCriteria(LocalDate from, LocalDate to) {
Instant fromInstant = from != null ? atStartOfDay(from) : null;
Instant toInstant = to != null ? atEndOfDay(to) : null;

if (fromInstant != null && toInstant != null) {
return Criteria.where("createdAt").gte(fromInstant).lte(toInstant);
} else if (fromInstant != null) {
return Criteria.where("createdAt").gte(fromInstant);
} else if (toInstant != null) {
return Criteria.where("createdAt").lte(toInstant);
}

return null;
}

private Instant atStartOfDay(LocalDate date) {
return date.atStartOfDay().toInstant(ZoneOffset.UTC);
}

private Instant atEndOfDay(LocalDate date) {
return date.atTime(23, 59, 59).toInstant(ZoneOffset.UTC);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package lv.ctco.springboottemplate.features.statistics.models;

import java.util.Map;

public record StatisticsDetailedDto(
long totalTodos,
long completedTodos,
long pendingTodos,
Map<String, Long> userStats,
StatisticsTodosDto todos) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package lv.ctco.springboottemplate.features.statistics.models;

import jakarta.validation.constraints.Pattern;
import java.time.LocalDate;
import lv.ctco.springboottemplate.validation.statistics.ValidStatisticsRequest;

@ValidStatisticsRequest
public record StatisticsRequestDto(
String from,
String to,
@Pattern(
regexp = "^(summary|detailed)$",
flags = Pattern.Flag.CASE_INSENSITIVE,
message = "{statistics.format.invalid}")
String format) {

public LocalDate fromDate() {
return from != null && !from.isBlank() ? LocalDate.parse(from) : null;
}

public LocalDate toDate() {
return to != null && !to.isBlank() ? LocalDate.parse(to) : null;
}

public String normalizedFormat() {
return format == null ? "summary" : format;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package lv.ctco.springboottemplate.features.statistics.models;

import java.util.Map;

public record StatisticsSummaryDto(
long totalTodos, long completedTodos, long pendingTodos, Map<String, Long> userStats) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package lv.ctco.springboottemplate.features.statistics.models;

import java.time.Instant;

public record StatisticsTodoItemDto(
String id, String title, String createdBy, Instant createdAt, Instant completedAt) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package lv.ctco.springboottemplate.features.statistics.models;

import java.util.List;

public record StatisticsTodosDto(
List<StatisticsTodoItemDto> completed, List<StatisticsTodoItemDto> pending) {}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package lv.ctco.springboottemplate.features.todo;

import java.time.Instant;
import java.util.List;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
Expand Down Expand Up @@ -27,4 +28,10 @@
@Repository
public interface TodoRepository extends MongoRepository<Todo, String> {
List<Todo> findByTitleContainingIgnoreCase(String title);

List<Todo> findByCreatedAtBetween(Instant from, Instant to);

List<Todo> findByCreatedAtGreaterThanEqual(Instant from);

List<Todo> findByCreatedAtLessThanEqual(Instant to);
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,16 @@ public boolean deleteTodo(String id) {
}
return false;
}

public List<Todo> getTodosByCreatedAtRange(Instant from, Instant to) {
if (from != null && to != null) {
return todoRepository.findByCreatedAtBetween(from, to);
} else if (from != null) {
return todoRepository.findByCreatedAtGreaterThanEqual(from);
} else if (to != null) {
return todoRepository.findByCreatedAtLessThanEqual(to);
}

return todoRepository.findAll();
}
}
Loading