From 5dd13d671a96606c81f8e23969d6eb5c212c6292 Mon Sep 17 00:00:00 2001 From: Nancy Huang <205217630+naanci@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:07:37 -0400 Subject: [PATCH] 838: add metrics api endpoint in UserController --- .../codebloom/api/user/UserController.java | 86 ++++++++- .../common/dto/user/metrics/MetricsDto.java | 38 ++++ .../properties/FeatureFlagConfiguration.java | 2 + src/main/resources/application-stg.yml | 3 + src/main/resources/application.yml | 1 + .../api/user/UserControllerTest.java | 180 +++++++++++++++++- 6 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/patinanetwork/codebloom/common/dto/user/metrics/MetricsDto.java diff --git a/src/main/java/org/patinanetwork/codebloom/api/user/UserController.java b/src/main/java/org/patinanetwork/codebloom/api/user/UserController.java index 679c1f921..1348a2716 100644 --- a/src/main/java/org/patinanetwork/codebloom/api/user/UserController.java +++ b/src/main/java/org/patinanetwork/codebloom/api/user/UserController.java @@ -15,16 +15,21 @@ import org.patinanetwork.codebloom.common.db.models.question.Question; import org.patinanetwork.codebloom.common.db.models.question.topic.LeetcodeTopicEnum; import org.patinanetwork.codebloom.common.db.models.user.User; +import org.patinanetwork.codebloom.common.db.models.user.UserMetrics; import org.patinanetwork.codebloom.common.db.repos.question.QuestionRepository; import org.patinanetwork.codebloom.common.db.repos.question.topic.service.QuestionTopicService; +import org.patinanetwork.codebloom.common.db.repos.user.UserMetricsRepository; import org.patinanetwork.codebloom.common.db.repos.user.UserRepository; +import org.patinanetwork.codebloom.common.db.repos.user.options.UserMetricsFilterOptions; import org.patinanetwork.codebloom.common.dto.ApiResponder; import org.patinanetwork.codebloom.common.dto.autogen.UnsafeGenericFailureResponse; import org.patinanetwork.codebloom.common.dto.question.QuestionDto; import org.patinanetwork.codebloom.common.dto.user.UserDto; +import org.patinanetwork.codebloom.common.dto.user.metrics.MetricsDto; import org.patinanetwork.codebloom.common.lag.FakeLag; import org.patinanetwork.codebloom.common.page.Page; import org.patinanetwork.codebloom.common.time.StandardizedOffsetDateTime; +import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -47,17 +52,25 @@ public class UserController { /* Page size for submissions */ private static final int SUBMISSIONS_PAGE_SIZE = 20; + private static final int METRICS_PAGE_SIZE = 20; + private final QuestionRepository questionRepository; private final UserRepository userRepository; private final QuestionTopicService questionTopicService; + private final UserMetricsRepository userMetricsRepository; + private final FeatureFlagConfiguration ff; public UserController( final QuestionRepository questionRepository, final UserRepository userRepository, - final QuestionTopicService questionTopicService) { + final QuestionTopicService questionTopicService, + final UserMetricsRepository userMetricsRepository, + final FeatureFlagConfiguration ff) { this.questionRepository = questionRepository; this.userRepository = userRepository; this.questionTopicService = questionTopicService; + this.userMetricsRepository = userMetricsRepository; + this.ff = ff; } @Operation( @@ -195,4 +208,75 @@ public ResponseEntity>> getAllUsers( return ResponseEntity.ok().body(ApiResponder.success("All users have been successfully fetched!", createdPage)); } + + @Operation( + summary = "Staging-only route that returns paginated metrics for a given user.", + description = """ + Returns a paginated list of collected metrics points for the given user within a date range. + Defaults to the last 7 days if no dates are provided. Only available in staging. + """, + responses = { + @ApiResponse(responseCode = "200", description = "Metrics fetched successfully"), + @ApiResponse( + responseCode = "400", + description = "Invalid date range (startDate is after endDate)", + content = @Content(schema = @Schema(implementation = UnsafeGenericFailureResponse.class))), + @ApiResponse( + responseCode = "403", + description = "Endpoint is not available in this environment", + content = @Content(schema = @Schema(implementation = UnsafeGenericFailureResponse.class))), + }) + @GetMapping("{userId}/metrics") + public ResponseEntity>> getUserMetrics( + final HttpServletRequest request, + @PathVariable final String userId, + @Parameter(description = "Page index", example = "1") @RequestParam(required = false, defaultValue = "1") + final int page, + @Parameter(description = "Page size (maximum of " + METRICS_PAGE_SIZE) + @RequestParam(required = false, defaultValue = "" + METRICS_PAGE_SIZE) + final int pageSize, + @Parameter(description = "Start date to filter metrics by createdAt (inclusive)") + @RequestParam(required = false) + final OffsetDateTime startDate, + @Parameter(description = "End date to filter metrics by createdAt (inclusive)") + @RequestParam(required = false) + final OffsetDateTime endDate) { + + FakeLag.sleep(500); + + if (!ff.isUserMetrics()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is not available."); + } + + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "startDate cannot be after endDate."); + } + + final OffsetDateTime resolvedEnd = endDate == null + ? StandardizedOffsetDateTime.normalize(OffsetDateTime.now()) + : StandardizedOffsetDateTime.normalize(endDate); + final OffsetDateTime resolvedStart = startDate == null + ? StandardizedOffsetDateTime.normalize(OffsetDateTime.now().minusWeeks(1)) + : StandardizedOffsetDateTime.normalize(startDate); + + final int parsedPageSize = Math.min(pageSize, METRICS_PAGE_SIZE); + + final UserMetricsFilterOptions options = UserMetricsFilterOptions.builder() + .page(page) + .pageSize(parsedPageSize) + .from(resolvedStart) + .to(resolvedEnd) + .build(); + + List metrics = userMetricsRepository.findUserMetrics(userId, options); + int totalMetrics = userMetricsRepository.countUserMetrics(userId, options); + int totalPages = (int) Math.ceil((double) totalMetrics / parsedPageSize); + boolean hasNextPage = page < totalPages; + + List metricsDtos = + metrics.stream().map(MetricsDto::fromUserMetrics).toList(); + Page createdPage = new Page<>(hasNextPage, metricsDtos, totalPages, parsedPageSize); + + return ResponseEntity.ok().body(ApiResponder.success("Metrics fetched!", createdPage)); + } } diff --git a/src/main/java/org/patinanetwork/codebloom/common/dto/user/metrics/MetricsDto.java b/src/main/java/org/patinanetwork/codebloom/common/dto/user/metrics/MetricsDto.java new file mode 100644 index 000000000..a1e1d0553 --- /dev/null +++ b/src/main/java/org/patinanetwork/codebloom/common/dto/user/metrics/MetricsDto.java @@ -0,0 +1,38 @@ +package org.patinanetwork.codebloom.common.dto.user.metrics; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.OffsetDateTime; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import lombok.extern.jackson.Jacksonized; +import org.patinanetwork.codebloom.common.db.models.user.UserMetrics; + +@Getter +@Jacksonized +@Builder +@ToString +@EqualsAndHashCode +public class MetricsDto { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String userId; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int points; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private OffsetDateTime createdAt; + + public static MetricsDto fromUserMetrics(final UserMetrics userMetrics) { + return MetricsDto.builder() + .id(userMetrics.getId()) + .userId(userMetrics.getUserId()) + .points(userMetrics.getPoints()) + .createdAt(userMetrics.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/org/patinanetwork/codebloom/jda/properties/FeatureFlagConfiguration.java b/src/main/java/org/patinanetwork/codebloom/jda/properties/FeatureFlagConfiguration.java index 433cf1c51..d0659943c 100644 --- a/src/main/java/org/patinanetwork/codebloom/jda/properties/FeatureFlagConfiguration.java +++ b/src/main/java/org/patinanetwork/codebloom/jda/properties/FeatureFlagConfiguration.java @@ -11,4 +11,6 @@ @ConfigurationProperties(prefix = "ff") public class FeatureFlagConfiguration { private boolean duels; + + private boolean userMetrics; } diff --git a/src/main/resources/application-stg.yml b/src/main/resources/application-stg.yml index 5d0e169bd..6a3727414 100644 --- a/src/main/resources/application-stg.yml +++ b/src/main/resources/application-stg.yml @@ -1,3 +1,6 @@ +ff: + user-metrics: true + # todo: re-enable this once inside of k8s cluster # logging: # structured: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 14a03a531..3f3d29cf3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -124,6 +124,7 @@ playwright: ff: duels: true + user-metrics: false resilience4j: retry: diff --git a/src/test/java/org/patinanetwork/codebloom/api/user/UserControllerTest.java b/src/test/java/org/patinanetwork/codebloom/api/user/UserControllerTest.java index 8a7d9632a..e3ffc3590 100644 --- a/src/test/java/org/patinanetwork/codebloom/api/user/UserControllerTest.java +++ b/src/test/java/org/patinanetwork/codebloom/api/user/UserControllerTest.java @@ -19,18 +19,23 @@ import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.patinanetwork.codebloom.common.db.models.question.Question; import org.patinanetwork.codebloom.common.db.models.user.User; +import org.patinanetwork.codebloom.common.db.models.user.UserMetrics; import org.patinanetwork.codebloom.common.db.repos.question.QuestionRepository; import org.patinanetwork.codebloom.common.db.repos.question.topic.service.QuestionTopicService; +import org.patinanetwork.codebloom.common.db.repos.user.UserMetricsRepository; import org.patinanetwork.codebloom.common.db.repos.user.UserRepository; import org.patinanetwork.codebloom.common.dto.question.QuestionDto; import org.patinanetwork.codebloom.common.dto.user.UserDto; +import org.patinanetwork.codebloom.common.dto.user.metrics.MetricsDto; import org.patinanetwork.codebloom.common.page.Page; +import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; @@ -42,10 +47,13 @@ public class UserControllerTest { private QuestionRepository questionRepository = mock(QuestionRepository.class); private UserRepository userRepository = mock(UserRepository.class); private QuestionTopicService questionTopicService = mock(QuestionTopicService.class); + private UserMetricsRepository userMetricsRepository = mock(UserMetricsRepository.class); + private FeatureFlagConfiguration ff = mock(FeatureFlagConfiguration.class); private HttpServletRequest request = mock(HttpServletRequest.class); public UserControllerTest() { - this.userController = new UserController(questionRepository, userRepository, questionTopicService); + this.userController = + new UserController(questionRepository, userRepository, questionTopicService, userMetricsRepository, ff); this.faker = Faker.instance(); } @@ -357,6 +365,176 @@ void getAllUsersEmptyResults() { assertEquals(0, page.getPages()); } + private UserMetrics createRandomUserMetrics(final String userId) { + return UserMetrics.builder() + .id(randomUUID()) + .userId(userId) + .points(faker.number().numberBetween(1, 100)) + .createdAt(OffsetDateTime.now().minusDays(faker.number().numberBetween(1, 6))) + .build(); + } + + @Test + @DisplayName("Get user metrics - feature flag disabled returns 403") + void getUserMetricsFeatureFlagDisabledReturnsForbidden() { + String userId = randomUUID(); + + when(ff.isUserMetrics()).thenReturn(false); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> { + userController.getUserMetrics(request, userId, 1, 20, null, null); + }); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + assertEquals("Endpoint is not available.", exception.getReason()); + } + + @Test + @DisplayName("Get user metrics - startDate after endDate returns 400") + void getUserMetricsInvalidDateRangeReturnsBadRequest() { + String userId = randomUUID(); + OffsetDateTime startDate = OffsetDateTime.now(); + OffsetDateTime endDate = startDate.minusDays(1); + + when(ff.isUserMetrics()).thenReturn(true); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> { + userController.getUserMetrics(request, userId, 1, 20, startDate, endDate); + }); + + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); + assertEquals("startDate cannot be after endDate.", exception.getReason()); + } + + @Test + @DisplayName("Get user metrics - returns metrics successfully with explicit date range") + void getUserMetricsReturnsSuccessfullyWithDateRange() { + String userId = randomUUID(); + OffsetDateTime startDate = OffsetDateTime.now().minusDays(7); + OffsetDateTime endDate = OffsetDateTime.now(); + + List metricsList = List.of(createRandomUserMetrics(userId), createRandomUserMetrics(userId)); + + when(ff.isUserMetrics()).thenReturn(true); + when(userMetricsRepository.findUserMetrics(eq(userId), any())).thenReturn(metricsList); + when(userMetricsRepository.countUserMetrics(eq(userId), any())).thenReturn(2); + + var response = userController.getUserMetrics(request, userId, 1, 20, startDate, endDate); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + + var apiResponder = response.getBody(); + assertNotNull(apiResponder); + assertTrue(apiResponder.isSuccess()); + assertEquals("Metrics fetched!", apiResponder.getMessage()); + + Page page = apiResponder.getPayload(); + assertNotNull(page); + assertEquals(2, page.getItems().size()); + assertEquals(1, page.getPages()); + assertEquals(20, page.getPageSize()); + + verify(userMetricsRepository, times(1)).findUserMetrics(eq(userId), any()); + verify(userMetricsRepository, times(1)).countUserMetrics(eq(userId), any()); + } + + @Test + @DisplayName("Get user metrics - defaults date range to last 7 days when not provided") + void getUserMetricsDefaultsToLastSevenDays() { + String userId = randomUUID(); + + when(ff.isUserMetrics()).thenReturn(true); + when(userMetricsRepository.findUserMetrics(eq(userId), any())).thenReturn(List.of()); + when(userMetricsRepository.countUserMetrics(eq(userId), any())).thenReturn(0); + + var response = userController.getUserMetrics(request, userId, 1, 20, null, null); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + + verify(userMetricsRepository, times(1)).findUserMetrics(eq(userId), any()); + } + + @Test + @DisplayName("Get user metrics - empty results") + void getUserMetricsEmptyResults() { + String userId = randomUUID(); + + when(ff.isUserMetrics()).thenReturn(true); + when(userMetricsRepository.findUserMetrics(eq(userId), any())).thenReturn(List.of()); + when(userMetricsRepository.countUserMetrics(eq(userId), any())).thenReturn(0); + + var response = userController.getUserMetrics(request, userId, 1, 20, null, null); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + + Page page = response.getBody().getPayload(); + assertNotNull(page); + assertEquals(0, page.getItems().size()); + assertEquals(0, page.getPages()); + } + + @Test + @DisplayName("Get user metrics - page size capped at maximum") + void getUserMetricsPageSizeCapped() { + String userId = randomUUID(); + + when(ff.isUserMetrics()).thenReturn(true); + when(userMetricsRepository.findUserMetrics(eq(userId), any())).thenReturn(List.of()); + when(userMetricsRepository.countUserMetrics(eq(userId), any())).thenReturn(0); + + var response = userController.getUserMetrics(request, userId, 1, 100, null, null); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + + Page page = response.getBody().getPayload(); + assertEquals(20, page.getPageSize()); + } + + @Test + @DisplayName("Get user metrics - pagination with multiple pages") + void getUserMetricsPaginationMultiplePages() { + String userId = randomUUID(); + + List metricsList = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + metricsList.add(createRandomUserMetrics(userId)); + } + + when(ff.isUserMetrics()).thenReturn(true); + when(userMetricsRepository.findUserMetrics(eq(userId), any())).thenReturn(metricsList); + when(userMetricsRepository.countUserMetrics(eq(userId), any())).thenReturn(45); + + var response = userController.getUserMetrics(request, userId, 1, 20, null, null); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + + Page page = response.getBody().getPayload(); + assertNotNull(page); + assertEquals(20, page.getItems().size()); + assertEquals(3, page.getPages()); + assertTrue(page.isHasNextPage()); + } + + @Test + @DisplayName("Get user metrics - dto maps all fields correctly") + void getUserMetricsDtoMapsFieldsCorrectly() { + String userId = randomUUID(); + UserMetrics metric = createRandomUserMetrics(userId); + + when(ff.isUserMetrics()).thenReturn(true); + when(userMetricsRepository.findUserMetrics(eq(userId), any())).thenReturn(List.of(metric)); + when(userMetricsRepository.countUserMetrics(eq(userId), any())).thenReturn(1); + + var response = userController.getUserMetrics(request, userId, 1, 20, null, null); + + MetricsDto dto = response.getBody().getPayload().getItems().get(0); + assertEquals(metric.getId(), dto.getId()); + assertEquals(metric.getUserId(), dto.getUserId()); + assertEquals(metric.getPoints(), dto.getPoints()); + assertEquals(metric.getCreatedAt(), dto.getCreatedAt()); + } + @Test @DisplayName("Get all users - pagination with multiple pages") void getAllUsersPaginationMultiplePages() {