diff --git a/src/main/java/com/bazaar/orders/client/UserClient.java b/src/main/java/com/bazaar/orders/client/UserClient.java index 7fcfb6f..bab91c2 100644 --- a/src/main/java/com/bazaar/orders/client/UserClient.java +++ b/src/main/java/com/bazaar/orders/client/UserClient.java @@ -1,12 +1,15 @@ 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 { /** @@ -14,7 +17,17 @@ public interface UserClient { * @param userId User's ID. * @return a {@link UserStatusResponseDTO}. */ - @GetExchange("/status") + @GetExchange("/users/status") ApiResponse 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 getUserStats(@RequestHeader("X-User-Role") Role role, + @RequestParam Integer period); + } diff --git a/src/main/java/com/bazaar/orders/config/global/GlobalExceptionHandler.java b/src/main/java/com/bazaar/orders/config/global/GlobalExceptionHandler.java index f529f7e..184c779 100644 --- a/src/main/java/com/bazaar/orders/config/global/GlobalExceptionHandler.java +++ b/src/main/java/com/bazaar/orders/config/global/GlobalExceptionHandler.java @@ -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; @@ -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 handleBadRequest(Exception ex, HttpServletRequest request) { LOGGER.error("Bad request exception: {}", ex.getMessage()); @@ -246,4 +248,4 @@ private ResponseEntity buildErrorResponse(HttpStatus status, Stri return ResponseEntity.status(status).contentType(MediaType.APPLICATION_PROBLEM_JSON).body(errorResponse); } -} \ No newline at end of file +} diff --git a/src/main/java/com/bazaar/orders/config/global/GlobalResponseAdvice.java b/src/main/java/com/bazaar/orders/config/global/GlobalResponseAdvice.java index d382a5d..9c8e084 100644 --- a/src/main/java/com/bazaar/orders/config/global/GlobalResponseAdvice.java +++ b/src/main/java/com/bazaar/orders/config/global/GlobalResponseAdvice.java @@ -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; } diff --git a/src/main/java/com/bazaar/orders/controller/AdminController.java b/src/main/java/com/bazaar/orders/controller/AdminController.java index ccdc94e..e94e95a 100644 --- a/src/main/java/com/bazaar/orders/controller/AdminController.java +++ b/src/main/java/com/bazaar/orders/controller/AdminController.java @@ -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") @@ -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 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); + } + } diff --git a/src/main/java/com/bazaar/orders/dto/user/UserStatsResponseDTO.java b/src/main/java/com/bazaar/orders/dto/user/UserStatsResponseDTO.java new file mode 100644 index 0000000..6fd6476 --- /dev/null +++ b/src/main/java/com/bazaar/orders/dto/user/UserStatsResponseDTO.java @@ -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) { +} diff --git a/src/main/java/com/bazaar/orders/service/AdminService.java b/src/main/java/com/bazaar/orders/service/AdminService.java index 7eb1afa..6e99bb1 100644 --- a/src/main/java/com/bazaar/orders/service/AdminService.java +++ b/src/main/java/com/bazaar/orders/service/AdminService.java @@ -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; @@ -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. @@ -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> 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 headers, List> rows) { + StringBuilder csv = new StringBuilder("\uFEFF"); + csv.append(headers.stream().map(this::escapeCsvCell).collect(Collectors.joining(","))); + + for (List 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("\"", "\"\"") + "\""; + } + } diff --git a/src/test/java/com/bazaar/orders/controller/AdminControllerTest.java b/src/test/java/com/bazaar/orders/controller/AdminControllerTest.java index e7946f2..780713a 100644 --- a/src/test/java/com/bazaar/orders/controller/AdminControllerTest.java +++ b/src/test/java/com/bazaar/orders/controller/AdminControllerTest.java @@ -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; @@ -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; @@ -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); diff --git a/src/test/java/com/bazaar/orders/testConfig/AbstractTest.java b/src/test/java/com/bazaar/orders/testConfig/AbstractTest.java index deec027..51146a3 100644 --- a/src/test/java/com/bazaar/orders/testConfig/AbstractTest.java +++ b/src/test/java/com/bazaar/orders/testConfig/AbstractTest.java @@ -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; @@ -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"); @@ -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 images = List.of(new ProductImageResponseDTO("https://example.com/image.png")); when(catalogClient.getProductById(anyLong())) @@ -336,6 +341,20 @@ public ResponseEntity getOrdersStats(Role role, Integer period) { .body(result.getResponseBody()); } + public ResponseEntity 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());