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
17 changes: 15 additions & 2 deletions src/main/java/com/bazaar/orders/client/UserClient.java
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
package com.bazaar.orders.client;

import com.bazaar.orders.apiResponse.ApiResponse;
import com.bazaar.orders.common.Role;
import com.bazaar.orders.dto.user.UserStatsResponseDTO;
import com.bazaar.orders.dto.user.UserStatusResponseDTO;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;

@HttpExchange("/users")
@HttpExchange
public interface UserClient {

/**
* Obtains a users status, that includes ID, role and account status.
* @param userId User's ID.
* @return a {@link UserStatusResponseDTO}.
*/
@GetExchange("/status")
@GetExchange("/users/status")
ApiResponse<UserStatusResponseDTO> getUserStatus(@RequestHeader("X-User-Id") Long userId);

/**
* Obtains user metrics for the administration panel.
* @param role Role of the user making the request, needs to be administrator.
* @param period The time period in days to filter metrics.
* @return a {@link UserStatsResponseDTO}.
*/
@GetExchange("/admin/users/stats")
ApiResponse<UserStatsResponseDTO> getUserStats(@RequestHeader("X-User-Role") Role role,
@RequestParam Integer period);

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.MissingPathVariableException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.annotation.ExceptionHandler;
Expand Down Expand Up @@ -73,7 +74,8 @@ public class GlobalExceptionHandler {
MethodArgumentTypeMismatchException.class, IllegalArgumentException.class, InvalidParameterException.class,
InvalidFormatException.class, MismatchedInputException.class, DataIntegrityViolationException.class,
InvalidDataAccessResourceUsageException.class, MissingRequestHeaderException.class,
MissingServletRequestPartException.class, MultipartException.class, MissingPathVariableException.class })
MissingServletRequestParameterException.class, MissingServletRequestPartException.class,
MultipartException.class, MissingPathVariableException.class })
public ResponseEntity<ErrorResponse> handleBadRequest(Exception ex, HttpServletRequest request) {
LOGGER.error("Bad request exception: {}", ex.getMessage());

Expand Down Expand Up @@ -246,4 +248,4 @@ private ResponseEntity<ErrorResponse> buildErrorResponse(HttpStatus status, Stri
return ResponseEntity.status(status).contentType(MediaType.APPLICATION_PROBLEM_JSON).body(errorResponse);
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ public Object beforeBodyWrite(Object body, @NotNull MethodParameter returnType,
return body;
}

if (MediaType.TEXT_PLAIN.includes(selectedContentType) || "text".equals(selectedContentType.getType())) {
return body;
}

if (body instanceof ApiResponse<?>) {
responseBody = body;
}
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/com/bazaar/orders/controller/AdminController.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDate;

@RestController
@RequiredArgsConstructor
@RequestMapping("/admin")
Expand Down Expand Up @@ -97,4 +102,29 @@ public OrderStatsResponseDTO getOrdersStats(@RequestHeader("X-User-Role") Role r
return adminService.getOrdersStats(role, period);
}

@Operation(summary = "Export dashboard metrics as CSV",
description = "Generates a CSV file with the full metrics dashboard data for the selected period.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "CSV generated successfully.",
content = @Content(mediaType = "text/csv")),
@ApiResponse(responseCode = "400", description = "Period is required.",
content = @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "401",
description = "User is not authorized or lacks the required role to perform this operation.",
content = @Content(mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class))) })
@GetMapping(value = "/orders/stats/export", produces = "text/csv")
public ResponseEntity<String> exportOrdersStats(@RequestHeader("X-User-Role") Role role,
@RequestParam Integer period) {
String filename = "bazaar-metricas-%d-%s.csv".formatted(period, LocalDate.now());
String csv = adminService.exportMetricsCsv(role, period);

return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(filename).build().toString())
.contentType(MediaType.parseMediaType("text/csv;charset=UTF-8"))
.body(csv);
}

}
12 changes: 12 additions & 0 deletions src/main/java/com/bazaar/orders/dto/user/UserStatsResponseDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.bazaar.orders.dto.user;

/**
* Data Transfer Object (DTO) representing user registration metrics.
*
* @param totalUsers The total number of registered users in the selected period.
* @param activeUsers The total number of active users in the selected period.
* @param blockedUsers The total number of blocked users in the selected period.
* @param growthRate The growth or decline compared to the previous identical period.
*/
public record UserStatsResponseDTO(Long totalUsers, Long activeUsers, Long blockedUsers, double growthRate) {
}
72 changes: 72 additions & 0 deletions src/main/java/com/bazaar/orders/service/AdminService.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
import com.bazaar.orders.common.OrderPaymentStatus;
import com.bazaar.orders.common.OrderStatus;
import com.bazaar.orders.common.Role;
import com.bazaar.orders.client.CatalogClient;
import com.bazaar.orders.client.UserClient;
import com.bazaar.orders.dto.catalog.ProductResponseDTO;
import com.bazaar.orders.dto.catalog.ProductRankingResponseDTO;
import com.bazaar.orders.dto.orders.OrderDailyStatsResponseDTO;
import com.bazaar.orders.dto.orders.OrderSummaryPageResponseDTO;
import com.bazaar.orders.dto.orders.OrderSummaryResponseDTO;
import com.bazaar.orders.dto.orders.OrderStatsResponseDTO;
import com.bazaar.orders.dto.user.UserStatsResponseDTO;
import com.bazaar.orders.exception.UserForbiddenException;
import com.bazaar.orders.model.Order;
import com.bazaar.orders.model.OrderItem;
Expand Down Expand Up @@ -46,6 +50,10 @@ public class AdminService {

private final OrderItemRepository orderItemRepository;

private final UserClient userClient;

private final CatalogClient catalogClient;

/**
* Checks the privileges of the user making the request. If role is not administrator,
* then it raises an exception.
Expand Down Expand Up @@ -208,4 +216,68 @@ public OrderStatsResponseDTO getOrdersStats(Role role, Integer days) {
statusDistribution, temporalEvolution, topSellingProducts);
}

/**
* Builds a CSV export containing all metrics displayed in the administration panel.
* @param role The {@link Role} of the user making the request.
* @param days The size of the evaluation window in days.
* @return A CSV document ready to be downloaded by the client.
*/
public String exportMetricsCsv(Role role, Integer days) {
checkPrivileges(role);

OrderStatsResponseDTO orders = getOrdersStats(role, days);
UserStatsResponseDTO users = userClient.getUserStats(role, days).data();

List<List<Object>> rows = new ArrayList<>();
rows.add(List.of("Período", "Días seleccionados", days, "", ""));
rows.add(List.of("Usuarios", "Registrados", users.totalUsers(), "Crecimiento %", users.growthRate()));
rows.add(List.of("Usuarios", "Activos", users.activeUsers(), "Bloqueados", users.blockedUsers()));
rows.add(List.of("Órdenes", "Total", orders.totalOrders(), "Crecimiento %", orders.totalOrdersGrowthRate()));
rows.add(List.of("Órdenes", "Facturado ARS", orders.totalTransacted(), "Crecimiento %",
orders.totalTransactedGrowthRate()));

orders.temporalEvolution()
.forEach(item -> rows
.add(List.of("Evolución temporal", item.date(), item.count(), "Ingresos ARS", item.revenue())));
orders.statusDistribution()
.forEach((status, count) -> rows.add(List.of("Distribución por estado", status, count, "", "")));
orders.topSellingProducts()
.forEach(item -> rows.add(List.of("Productos más vendidos", getProductTitle(item.productId()),
item.totalQuantity(), "Ingresos ARS", item.totalRevenue())));

return generateCsv(List.of("Sección", "Métrica", "Valor 1", "Valor 2", "Valor 3"), rows);
}

private String getProductTitle(Long productId) {
try {
ProductResponseDTO product = catalogClient.getProductById(productId).data();
if (product != null && product.title() != null && !product.title().isBlank())
return product.title();
}
catch (Exception ignored) {
LOGGER.warn("Could not fetch product {} for metrics CSV export", productId);
}

return "Producto #" + productId;
}

private String generateCsv(List<String> headers, List<List<Object>> rows) {
StringBuilder csv = new StringBuilder("\uFEFF");
csv.append(headers.stream().map(this::escapeCsvCell).collect(Collectors.joining(",")));

for (List<Object> row : rows) {
csv.append('\n');
csv.append(row.stream().map(this::escapeCsvCell).collect(Collectors.joining(",")));
}

return csv.toString();
}

private String escapeCsvCell(Object cell) {
if (cell == null)
return "\"\"";

return "\"" + cell.toString().replace("\"", "\"\"") + "\"";
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;

import java.math.BigDecimal;
Expand All @@ -24,6 +26,23 @@
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AdminControllerTest extends AbstractTest {

private OrderPayment latestPayment() {
return orderPaymentRepository.findAll().stream().max(Comparator.comparing(OrderPayment::getId)).orElseThrow();
}

private void createOrderWithPaymentStatus(Long userId, String status, String... itemsJson) {
assertEquals(HttpStatus.OK,
postCreateOrder(userId, createOrderJson("Av. Corrientes 1234, CABA", itemsJson)).getStatusCode());

if (status == null)
return;

OrderPayment payment = latestPayment();
assertDoesNotThrow(() -> mockMercadoPagoPaymentWebhook(payment.getExternalReference(), status));
assertEquals(HttpStatus.OK,
postMercadoPagoWebhook(createMercadoPagoPaymentWebhookJson("payment")).getStatusCode());
}

@Test
void testGetSystemOrders() {
Long userId = 1L;
Expand Down Expand Up @@ -209,6 +228,38 @@ void testNonAdministratorsCannotGetOrdersStats() {
assertEquals(HttpStatus.FORBIDDEN, getOrdersStats(Role.USER, 30).getStatusCode());
}

@Test
void testExportOrdersStatsAsCsv() {
createOrderWithPaymentStatus(1L, "approved",
createOrderItemJson(2L, 10L, new BigDecimal("10.00"), 2, "Tecnologia"));

var exportResponse = exportOrdersStats(Role.ADMIN, 30);
assertEquals(HttpStatus.OK, exportResponse.getStatusCode());
assertEquals(MediaType.parseMediaType("text/csv;charset=UTF-8"), exportResponse.getHeaders().getContentType());
assertTrue(
exportResponse.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION).contains("bazaar-metricas-30"));

String csv = exportResponse.getBody();
assertTrue(csv.contains("\"Sección\",\"Métrica\",\"Valor 1\",\"Valor 2\",\"Valor 3\""));
assertTrue(csv.contains("\"Usuarios\",\"Registrados\",\"10\",\"Crecimiento %\",\"15.5\""));
assertTrue(csv.contains("\"Órdenes\",\"Facturado ARS\",\"20.00\",\"Crecimiento %\",\"100.0\""));
assertTrue(csv.contains("\"Productos más vendidos\",\"Title\",\"2\",\"Ingresos ARS\",\"20.00\""));
}

@Test
void testExportOrdersStatsRequiresPeriod() {
var exportResponse = exportOrdersStats(Role.ADMIN, null);

assertEquals(HttpStatus.BAD_REQUEST, exportResponse.getStatusCode());
}

@Test
void testNonAdministratorsCannotExportOrdersStats() {
var exportResponse = exportOrdersStats(Role.USER, 30);

assertEquals(HttpStatus.FORBIDDEN, exportResponse.getStatusCode());
}

@Test
void testGetOrdersStatsUnauthorizedForNonAdminRoles() {
var statsResponse = getOrdersStats(Role.USER, 30);
Expand Down
19 changes: 19 additions & 0 deletions src/test/java/com/bazaar/orders/testConfig/AbstractTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.bazaar.orders.common.UserStatus;
import com.bazaar.orders.dto.catalog.ProductImageResponseDTO;
import com.bazaar.orders.dto.catalog.ProductResponseDTO;
import com.bazaar.orders.dto.user.UserStatsResponseDTO;
import com.bazaar.orders.dto.user.UserStatusResponseDTO;
import com.bazaar.orders.repository.OrderItemRepository;
import com.bazaar.orders.repository.OrderPaymentRepository;
Expand Down Expand Up @@ -97,6 +98,8 @@ public class AbstractTest {

public static final String GET_ORDERS_STATS_URL = "/admin/orders/stats";

public static final String EXPORT_ORDERS_STATS_URL = "/admin/orders/stats/export";

static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine").withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
Expand Down Expand Up @@ -175,6 +178,8 @@ public static String createUpdateOrderStatusJson(String trackingCode) {
void setUp() throws MPException, MPApiException {
when(userClient.getUserStatus(anyLong()))
.thenReturn(new ApiResponse<>(new UserStatusResponseDTO(1L, "MOCKED", UserStatus.ACTIVE)));
when(userClient.getUserStats(any(Role.class), anyInt()))
.thenReturn(new ApiResponse<>(new UserStatsResponseDTO(10L, 8L, 2L, 15.5)));

List<ProductImageResponseDTO> images = List.of(new ProductImageResponseDTO("https://example.com/image.png"));
when(catalogClient.getProductById(anyLong()))
Expand Down Expand Up @@ -336,6 +341,20 @@ public ResponseEntity<String> getOrdersStats(Role role, Integer period) {
.body(result.getResponseBody());
}

public ResponseEntity<String> exportOrdersStats(Role role, Integer period) {
var result = webTestClient.get().uri(uriBuilder -> {
var builder = uriBuilder.path(EXPORT_ORDERS_STATS_URL);
if (period != null) {
builder.queryParam("period", period);
}
return builder.build();
}).header("X-User-Role", role.name()).exchange().expectBody(String.class).returnResult();

return ResponseEntity.status(result.getStatus())
.headers(result.getResponseHeaders())
.body(result.getResponseBody());
}

public void assertPageResponse(String body, Integer page, Integer size, Integer totalElements, Integer totalPages) {
assertEquals(page, ((Number) JsonPath.read(body, "$.data.page")).intValue());
assertEquals(size, ((Number) JsonPath.read(body, "$.data.size")).intValue());
Expand Down
Loading