diff --git a/src/main/java/io/getstream/chat/java/models/TeamUsageStats.java b/src/main/java/io/getstream/chat/java/models/TeamUsageStats.java new file mode 100644 index 00000000..53df0a9b --- /dev/null +++ b/src/main/java/io/getstream/chat/java/models/TeamUsageStats.java @@ -0,0 +1,233 @@ +package io.getstream.chat.java.models; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.getstream.chat.java.models.TeamUsageStats.QueryTeamUsageStatsRequestData.QueryTeamUsageStatsRequest; +import io.getstream.chat.java.models.framework.StreamRequest; +import io.getstream.chat.java.models.framework.StreamResponseObject; +import io.getstream.chat.java.services.StatsService; +import io.getstream.chat.java.services.framework.Client; +import java.util.List; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import retrofit2.Call; + +/** Team-level usage statistics for multi-tenant apps. */ +@Data +@NoArgsConstructor +public class TeamUsageStats { + + /** Team identifier (empty string for users not assigned to any team). */ + @NotNull + @JsonProperty("team") + private String team; + + // Daily activity metrics (total = SUM of daily values) + + /** Daily active users. */ + @NotNull + @JsonProperty("users_daily") + private MetricStats usersDaily; + + /** Daily messages sent. */ + @NotNull + @JsonProperty("messages_daily") + private MetricStats messagesDaily; + + /** Daily translations. */ + @NotNull + @JsonProperty("translations_daily") + private MetricStats translationsDaily; + + /** Daily image moderations. */ + @NotNull + @JsonProperty("image_moderations_daily") + private MetricStats imageModerationDaily; + + // Peak metrics (total = MAX of daily values) + + /** Peak concurrent users. */ + @NotNull + @JsonProperty("concurrent_users") + private MetricStats concurrentUsers; + + /** Peak concurrent connections. */ + @NotNull + @JsonProperty("concurrent_connections") + private MetricStats concurrentConnections; + + // Rolling/cumulative metrics (total = LATEST daily value) + + /** Total users. */ + @NotNull + @JsonProperty("users_total") + private MetricStats usersTotal; + + /** Users active in last 24 hours. */ + @NotNull + @JsonProperty("users_last_24_hours") + private MetricStats usersLast24Hours; + + /** MAU - users active in last 30 days. */ + @NotNull + @JsonProperty("users_last_30_days") + private MetricStats usersLast30Days; + + /** Users active this month. */ + @NotNull + @JsonProperty("users_month_to_date") + private MetricStats usersMonthToDate; + + /** Engaged MAU. */ + @NotNull + @JsonProperty("users_engaged_last_30_days") + private MetricStats usersEngagedLast30Days; + + /** Engaged users this month. */ + @NotNull + @JsonProperty("users_engaged_month_to_date") + private MetricStats usersEngagedMonthToDate; + + /** Total messages. */ + @NotNull + @JsonProperty("messages_total") + private MetricStats messagesTotal; + + /** Messages in last 24 hours. */ + @NotNull + @JsonProperty("messages_last_24_hours") + private MetricStats messagesLast24Hours; + + /** Messages in last 30 days. */ + @NotNull + @JsonProperty("messages_last_30_days") + private MetricStats messagesLast30Days; + + /** Messages this month. */ + @NotNull + @JsonProperty("messages_month_to_date") + private MetricStats messagesMonthToDate; + + /** Statistics for a single metric with optional daily breakdown. */ + @Data + @NoArgsConstructor + public static class MetricStats { + /** Per-day values (only present in daily mode). */ + @Nullable + @JsonProperty("daily") + private List daily; + + /** Aggregated total value. */ + @NotNull + @JsonProperty("total") + private Long total; + } + + /** Represents a metric value for a specific date. */ + @Data + @NoArgsConstructor + public static class DailyValue { + /** Date in YYYY-MM-DD format. */ + @NotNull + @JsonProperty("date") + private String date; + + /** Metric value for this date. */ + @NotNull + @JsonProperty("value") + private Long value; + } + + @Builder( + builderClassName = "QueryTeamUsageStatsRequest", + builderMethodName = "", + buildMethodName = "internalBuild") + @Getter + @EqualsAndHashCode + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class QueryTeamUsageStatsRequestData { + /** + * Month in YYYY-MM format (e.g., '2026-01'). Mutually exclusive with start_date/end_date. + * Returns aggregated monthly values. + */ + @Nullable + @JsonProperty("month") + private String month; + + /** + * Start date in YYYY-MM-DD format. Used with end_date for custom date range. Returns daily + * breakdown. + */ + @Nullable + @JsonProperty("start_date") + private String startDate; + + /** + * End date in YYYY-MM-DD format. Used with start_date for custom date range. Returns daily + * breakdown. + */ + @Nullable + @JsonProperty("end_date") + private String endDate; + + /** Maximum number of teams to return per page (default: 30, max: 30). */ + @Nullable + @JsonProperty("limit") + private Integer limit; + + /** Cursor for pagination to fetch next page of teams. */ + @Nullable + @JsonProperty("next") + private String next; + + public static class QueryTeamUsageStatsRequest + extends StreamRequest { + @Override + protected Call generateCall(Client client) { + return client.create(StatsService.class).queryTeamUsageStats(this.internalBuild()); + } + } + } + + @Data + @NoArgsConstructor + @EqualsAndHashCode(callSuper = true) + public static class QueryTeamUsageStatsResponse extends StreamResponseObject { + /** Array of team usage statistics. */ + @NotNull + @JsonProperty("teams") + private List teams; + + /** Cursor for pagination to fetch next page. */ + @Nullable + @JsonProperty("next") + private String next; + } + + /** + * Queries team-level usage statistics from the warehouse database. + * + *

Returns all 16 metrics grouped by team with cursor-based pagination. + * + *

Date Range Options (mutually exclusive): + * + *

    + *
  • Use 'month' parameter (YYYY-MM format) for monthly aggregated values + *
  • Use 'startDate'/'endDate' parameters (YYYY-MM-DD format) for daily breakdown + *
  • If neither provided, defaults to current month (monthly mode) + *
+ * + *

This endpoint is server-side only. + * + * @return the created request + */ + @NotNull + public static QueryTeamUsageStatsRequest queryTeamUsageStats() { + return new QueryTeamUsageStatsRequest(); + } +} diff --git a/src/main/java/io/getstream/chat/java/services/StatsService.java b/src/main/java/io/getstream/chat/java/services/StatsService.java new file mode 100644 index 00000000..05772042 --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/StatsService.java @@ -0,0 +1,14 @@ +package io.getstream.chat.java.services; + +import io.getstream.chat.java.models.TeamUsageStats.QueryTeamUsageStatsRequestData; +import io.getstream.chat.java.models.TeamUsageStats.QueryTeamUsageStatsResponse; +import org.jetbrains.annotations.NotNull; +import retrofit2.Call; +import retrofit2.http.Body; +import retrofit2.http.POST; + +public interface StatsService { + @POST("stats/team_usage") + Call queryTeamUsageStats( + @NotNull @Body QueryTeamUsageStatsRequestData queryTeamUsageStatsRequestData); +} diff --git a/src/test/java/io/getstream/chat/java/TeamUsageStatsTest.java b/src/test/java/io/getstream/chat/java/TeamUsageStatsTest.java new file mode 100644 index 00000000..254c35a3 --- /dev/null +++ b/src/test/java/io/getstream/chat/java/TeamUsageStatsTest.java @@ -0,0 +1,157 @@ +package io.getstream.chat.java; + +import io.getstream.chat.java.models.TeamUsageStats; +import io.getstream.chat.java.models.TeamUsageStats.QueryTeamUsageStatsResponse; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class TeamUsageStatsTest { + + @DisplayName("Can query team usage stats with default options") + @Test + void whenQueryingTeamUsageStatsWithDefaultOptions_thenNoException() { + QueryTeamUsageStatsResponse response = + Assertions.assertDoesNotThrow(() -> TeamUsageStats.queryTeamUsageStats().request()); + + Assertions.assertNotNull(response); + Assertions.assertNotNull(response.getTeams()); + // Teams list might be empty if there's no usage data + } + + @DisplayName("Can query team usage stats with month parameter") + @Test + void whenQueryingTeamUsageStatsWithMonth_thenNoException() { + // Use current month + String currentMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")); + + QueryTeamUsageStatsResponse response = + Assertions.assertDoesNotThrow( + () -> TeamUsageStats.queryTeamUsageStats().month(currentMonth).request()); + + Assertions.assertNotNull(response); + Assertions.assertNotNull(response.getTeams()); + } + + @DisplayName("Can query team usage stats with date range") + @Test + void whenQueryingTeamUsageStatsWithDateRange_thenNoException() { + // Use last 7 days + LocalDate endDate = LocalDate.now(); + LocalDate startDate = endDate.minusDays(7); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + QueryTeamUsageStatsResponse response = + Assertions.assertDoesNotThrow( + () -> + TeamUsageStats.queryTeamUsageStats() + .startDate(startDate.format(formatter)) + .endDate(endDate.format(formatter)) + .request()); + + Assertions.assertNotNull(response); + Assertions.assertNotNull(response.getTeams()); + + // If there are teams with data, verify the daily breakdown is present + if (!response.getTeams().isEmpty()) { + TeamUsageStats team = response.getTeams().get(0); + Assertions.assertNotNull(team.getTeam()); + Assertions.assertNotNull(team.getUsersDaily()); + Assertions.assertNotNull(team.getMessagesDaily()); + } + } + + @DisplayName("Can query team usage stats with pagination") + @Test + void whenQueryingTeamUsageStatsWithPagination_thenNoException() { + // First page with limit + QueryTeamUsageStatsResponse firstPage = + Assertions.assertDoesNotThrow( + () -> TeamUsageStats.queryTeamUsageStats().limit(10).request()); + + Assertions.assertNotNull(firstPage); + Assertions.assertNotNull(firstPage.getTeams()); + + // If there's a next cursor, fetch the next page + if (firstPage.getNext() != null && !firstPage.getNext().isEmpty()) { + QueryTeamUsageStatsResponse secondPage = + Assertions.assertDoesNotThrow( + () -> + TeamUsageStats.queryTeamUsageStats() + .limit(10) + .next(firstPage.getNext()) + .request()); + + Assertions.assertNotNull(secondPage); + Assertions.assertNotNull(secondPage.getTeams()); + } + } + + @DisplayName("Can query team usage stats for last year and verify response structure") + @Test + void whenQueryingTeamUsageStats_thenResponseStructureIsCorrect() { + // Query last year to maximize chance of getting data + LocalDate endDate = LocalDate.now(); + LocalDate startDate = endDate.minusYears(1); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + QueryTeamUsageStatsResponse response = + Assertions.assertDoesNotThrow( + () -> + TeamUsageStats.queryTeamUsageStats() + .startDate(startDate.format(formatter)) + .endDate(endDate.format(formatter)) + .request()); + + Assertions.assertNotNull(response); + Assertions.assertNotNull(response.getTeams()); + + // Verify response structure + List teams = response.getTeams(); + if (!teams.isEmpty()) { + TeamUsageStats team = teams.get(0); + + // Verify all 16 metrics are present + Assertions.assertNotNull(team.getTeam(), "Team identifier should not be null"); + + // Daily activity metrics + Assertions.assertNotNull(team.getUsersDaily(), "users_daily should not be null"); + Assertions.assertNotNull(team.getMessagesDaily(), "messages_daily should not be null"); + Assertions.assertNotNull( + team.getTranslationsDaily(), "translations_daily should not be null"); + Assertions.assertNotNull( + team.getImageModerationDaily(), "image_moderations_daily should not be null"); + + // Peak metrics + Assertions.assertNotNull(team.getConcurrentUsers(), "concurrent_users should not be null"); + Assertions.assertNotNull( + team.getConcurrentConnections(), "concurrent_connections should not be null"); + + // Rolling/cumulative metrics + Assertions.assertNotNull(team.getUsersTotal(), "users_total should not be null"); + Assertions.assertNotNull( + team.getUsersLast24Hours(), "users_last_24_hours should not be null"); + Assertions.assertNotNull(team.getUsersLast30Days(), "users_last_30_days should not be null"); + Assertions.assertNotNull( + team.getUsersMonthToDate(), "users_month_to_date should not be null"); + Assertions.assertNotNull( + team.getUsersEngagedLast30Days(), "users_engaged_last_30_days should not be null"); + Assertions.assertNotNull( + team.getUsersEngagedMonthToDate(), "users_engaged_month_to_date should not be null"); + Assertions.assertNotNull(team.getMessagesTotal(), "messages_total should not be null"); + Assertions.assertNotNull( + team.getMessagesLast24Hours(), "messages_last_24_hours should not be null"); + Assertions.assertNotNull( + team.getMessagesLast30Days(), "messages_last_30_days should not be null"); + Assertions.assertNotNull( + team.getMessagesMonthToDate(), "messages_month_to_date should not be null"); + + // Verify MetricStats structure + Assertions.assertNotNull( + team.getUsersDaily().getTotal(), "MetricStats total should not be null"); + } + } +}