From 3cc02ae7bfe62d7dca14faa06eab94ee43f8a7f7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 3 Jul 2026 04:52:28 -0400 Subject: [PATCH] admin dashboard spend (#1729) --- ...423142411010f298f0815eacc7d9c3138bdd8.json | 113 ++++++ .../artcraft_api_defs/src/moderation/mod.rs | 1 + .../src/moderation/top_spenders/list.rs | 82 +++++ .../src/moderation/top_spenders/mod.rs | 1 + .../list_top_spenders_for_window.rs | 78 +++++ .../src/queries/user_spend_events/mod.rs | 1 + .../web/storyteller_web/src/docs/api_doc.rs | 5 + .../http_server/endpoints/moderation/mod.rs | 1 + .../endpoints/moderation/top_spenders/mod.rs | 1 + .../moderator_list_top_spenders_handler.rs | 93 +++++ .../application_routes/moderation_routes.rs | 7 + frontend/apps/artcraft-dashboard/src/App.tsx | 2 + .../src/api/ModerationApi.ts | 49 +++ .../src/components/app-sidebar.tsx | 2 + .../src/pages/SpendEvents.tsx | 24 +- .../src/pages/TopSpenders.tsx | 331 ++++++++++++++++++ 16 files changed, 790 insertions(+), 1 deletion(-) create mode 100644 .sqlx/query-c2ee41eb6bc1a6a551411998155423142411010f298f0815eacc7d9c3138bdd8.json create mode 100644 crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/list.rs create mode 100644 crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/mod.rs create mode 100644 crates/schema/database/mysql_queries/src/queries/user_spend_events/list_top_spenders_for_window.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/moderation/top_spenders/mod.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/moderation/top_spenders/moderator_list_top_spenders_handler.rs create mode 100644 frontend/apps/artcraft-dashboard/src/pages/TopSpenders.tsx diff --git a/.sqlx/query-c2ee41eb6bc1a6a551411998155423142411010f298f0815eacc7d9c3138bdd8.json b/.sqlx/query-c2ee41eb6bc1a6a551411998155423142411010f298f0815eacc7d9c3138bdd8.json new file mode 100644 index 0000000000..c9538165bc --- /dev/null +++ b/.sqlx/query-c2ee41eb6bc1a6a551411998155423142411010f298f0815eacc7d9c3138bdd8.json @@ -0,0 +1,113 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT\n e.maybe_user_token AS `user_token!: UserToken`,\n u.username AS `username!`,\n u.display_name AS `display_name!`,\n u.email_gravatar_hash AS `email_gravatar_hash!`,\n CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN e.amount_usd_cents < 0 THEN -e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`,\n CAST(SUM(e.amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`,\n CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`,\n CAST(COALESCE(SUM(e.maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64`\nFROM user_spend_events e\nJOIN users u ON u.token = e.maybe_user_token\nWHERE e.maybe_user_token IS NOT NULL\n AND e.is_production = TRUE\n AND e.payment_occurred_at >= ?\n AND (? IS NULL OR e.payments_namespace = ?)\nGROUP BY e.maybe_user_token, u.username, u.display_name, u.email_gravatar_hash\nORDER BY SUM(e.amount_usd_cents) DESC, e.maybe_user_token ASC\nLIMIT ? OFFSET ?\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_token!: UserToken", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 1, + "name": "username!", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 80 + } + }, + { + "ordinal": 2, + "name": "display_name!", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 80 + } + }, + { + "ordinal": 3, + "name": "email_gravatar_hash!", + "type_info": { + "type": "String", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 4, + "name": "gross_spend_usd_cents!: u64", + "type_info": { + "type": "LongLong", + "flags": "UNSIGNED | BINARY", + "char_set": 63, + "max_size": 21 + } + }, + { + "ordinal": 5, + "name": "refund_usd_cents!: u64", + "type_info": { + "type": "LongLong", + "flags": "UNSIGNED | BINARY", + "char_set": 63, + "max_size": 21 + } + }, + { + "ordinal": 6, + "name": "net_spend_usd_cents!: i64", + "type_info": { + "type": "LongLong", + "flags": "BINARY", + "char_set": 63, + "max_size": 21 + } + }, + { + "ordinal": 7, + "name": "payment_count!: u64", + "type_info": { + "type": "LongLong", + "flags": "UNSIGNED | BINARY", + "char_set": 63, + "max_size": 21 + } + }, + { + "ordinal": 8, + "name": "credits_granted!: u64", + "type_info": { + "type": "LongLong", + "flags": "NOT_NULL | UNSIGNED | BINARY", + "char_set": 63, + "max_size": 21 + } + } + ], + "parameters": { + "Right": 5 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true, + false + ] + }, + "hash": "c2ee41eb6bc1a6a551411998155423142411010f298f0815eacc7d9c3138bdd8" +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/mod.rs index c5b7565be2..154098c1fa 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/mod.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/mod.rs @@ -1,5 +1,6 @@ pub mod alerts; pub mod debug_logs; +pub mod top_spenders; pub mod jobs; pub mod user; pub mod user_referrals; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/list.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/list.rs new file mode 100644 index 0000000000..8977e9eb74 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/list.rs @@ -0,0 +1,82 @@ +use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::users::UserToken; +use utoipa::{IntoParams, ToSchema}; + +pub const MODERATOR_LIST_TOP_SPENDERS_PATH: &str = + "/v1/moderation/top_spenders/list"; + +/// Rolling aggregation window for the top spenders list. +#[derive(Deserialize, Serialize, Clone, Copy, ToSchema)] +pub enum TopSpendersWindow { + #[serde(rename = "24h")] + TwentyFourHours, + #[serde(rename = "7d")] + SevenDays, + #[serde(rename = "14d")] + FourteenDays, + #[serde(rename = "30d")] + ThirtyDays, + #[serde(rename = "60d")] + SixtyDays, + #[serde(rename = "90d")] + NinetyDays, + #[serde(rename = "180d")] + OneHundredEightyDays, + #[serde(rename = "365d")] + ThreeHundredSixtyFiveDays, +} + +impl TopSpendersWindow { + /// Window length in hours. + pub fn as_hours(self) -> i64 { + match self { + TopSpendersWindow::TwentyFourHours => 24, + TopSpendersWindow::SevenDays => 7 * 24, + TopSpendersWindow::FourteenDays => 14 * 24, + TopSpendersWindow::ThirtyDays => 30 * 24, + TopSpendersWindow::SixtyDays => 60 * 24, + TopSpendersWindow::NinetyDays => 90 * 24, + TopSpendersWindow::OneHundredEightyDays => 180 * 24, + TopSpendersWindow::ThreeHundredSixtyFiveDays => 365 * 24, + } + } +} + +#[derive(Deserialize, IntoParams)] +pub struct ModeratorListTopSpendersQueryParams { + /// Aggregation window: `24h`, `7d`, `14d`, `30d` (default), `60d`, `90d`, + /// `180d`, or `365d`. + pub window: Option, + /// Row offset for pagination (page size is fixed at 100). Pass `next_offset` from the previous page. + pub offset: Option, + /// Optional namespace filter (e.g. `artcraft`). Omit to return all namespaces. + pub payments_namespace: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ModeratorListTopSpendersResponse { + pub success: bool, + /// The window the results were aggregated over (echoed back, post-default). + pub window: TopSpendersWindow, + pub spenders: Vec, + /// Offset for the next page, or `None` if this was the last page. + pub maybe_next_offset: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct TopSpenderEntry { + pub user_token: UserToken, + pub username: String, + pub display_name: String, + pub email_gravatar_hash: String, + /// Sum of positive amounts in the window. + pub gross_spend_usd_cents: u64, + /// Refund/chargeback magnitude in the window (positive number). + pub refund_usd_cents: u64, + /// Gross minus refunds. Can be negative if refunds outweigh purchases. + pub net_spend_usd_cents: i64, + /// Number of positive money events in the window. + pub payment_count: u64, + /// Credits granted in the window. + pub credits_granted: u64, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/mod.rs new file mode 100644 index 0000000000..d17e233fbf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/moderation/top_spenders/mod.rs @@ -0,0 +1 @@ +pub mod list; diff --git a/crates/schema/database/mysql_queries/src/queries/user_spend_events/list_top_spenders_for_window.rs b/crates/schema/database/mysql_queries/src/queries/user_spend_events/list_top_spenders_for_window.rs new file mode 100644 index 0000000000..87336e7823 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_spend_events/list_top_spenders_for_window.rs @@ -0,0 +1,78 @@ +use std::marker::PhantomData; + +use chrono::{DateTime, Utc}; +use sqlx::{Executor, MySql}; + +use tokens::tokens::users::UserToken; + +/// One user's aggregated spend within the requested window, joined to their +/// display info. Only attributed (non-null user), production events count. +pub struct TopSpenderForWindow { + pub user_token: UserToken, + pub username: String, + pub display_name: String, + pub email_gravatar_hash: String, + pub gross_spend_usd_cents: u64, + pub refund_usd_cents: u64, + pub net_spend_usd_cents: i64, + pub payment_count: u64, + pub credits_granted: u64, +} + +pub struct ListTopSpendersForWindowArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + /// Inclusive start of the aggregation window. + pub window_start: DateTime, + /// Optional namespace filter. `None` aggregates across all namespaces. + pub maybe_payments_namespace: Option<&'e str>, + pub limit: i64, + pub offset: i64, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Top spenders since `window_start`, sorted by net spend descending. +/// Aggregates `user_spend_events` directly (exact rolling windows, unlike the +/// daily rollups) — the table is payment-scale, so this stays cheap. +pub async fn list_top_spenders_for_window<'e, 'c: 'e, E>( + args: ListTopSpendersForWindowArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + let rows = sqlx::query_as!( + TopSpenderForWindow, + r#" +SELECT + e.maybe_user_token AS `user_token!: UserToken`, + u.username AS `username!`, + u.display_name AS `display_name!`, + u.email_gravatar_hash AS `email_gravatar_hash!`, + CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`, + CAST(SUM(CASE WHEN e.amount_usd_cents < 0 THEN -e.amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`, + CAST(SUM(e.amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`, + CAST(SUM(CASE WHEN e.amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`, + CAST(COALESCE(SUM(e.maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64` +FROM user_spend_events e +JOIN users u ON u.token = e.maybe_user_token +WHERE e.maybe_user_token IS NOT NULL + AND e.is_production = TRUE + AND e.payment_occurred_at >= ? + AND (? IS NULL OR e.payments_namespace = ?) +GROUP BY e.maybe_user_token, u.username, u.display_name, u.email_gravatar_hash +ORDER BY SUM(e.amount_usd_cents) DESC, e.maybe_user_token ASC +LIMIT ? OFFSET ? + "#, + args.window_start, + args.maybe_payments_namespace, + args.maybe_payments_namespace, + args.limit, + args.offset, + ) + .fetch_all(args.mysql_executor) + .await?; + + Ok(rows) +} diff --git a/crates/schema/database/mysql_queries/src/queries/user_spend_events/mod.rs b/crates/schema/database/mysql_queries/src/queries/user_spend_events/mod.rs index e0c1ea8ee6..beaef529f7 100644 --- a/crates/schema/database/mysql_queries/src/queries/user_spend_events/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/user_spend_events/mod.rs @@ -1,3 +1,4 @@ +pub mod list_top_spenders_for_window; pub mod list_user_spend_events_for_moderation; pub mod backfill_get_user_token_by_email; pub mod backfill_list_artcraft_customer_links; diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 09968c171a..f73acb2bdf 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -137,6 +137,7 @@ use artcraft_api_defs::moderation::user_spend_summaries::get_summary::*; use artcraft_api_defs::moderation::user_spend_summaries::reengagement_list::*; use artcraft_api_defs::moderation::user_spend_summaries::top_users_list::*; use artcraft_api_defs::moderation::user_daily_spends::user_daily_spends_list::*; +use artcraft_api_defs::moderation::top_spenders::list::*; use artcraft_api_defs::moderation::user_spend_events::list::*; use artcraft_api_defs::folders::subfolder::*; use artcraft_api_defs::moderation::user_referrals::list_global_user_referrals::*; @@ -396,6 +397,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux crate::http_server::endpoints::moderation::user_spend_summaries::moderator_reengagement_list_handler::moderator_reengagement_list_handler, crate::http_server::endpoints::moderation::user_spend_summaries::moderator_top_users_list_handler::moderator_top_users_list_handler, crate::http_server::endpoints::moderation::user_daily_spends::moderator_user_daily_spends_handler::moderator_user_daily_spends_handler, + crate::http_server::endpoints::moderation::top_spenders::moderator_list_top_spenders_handler::moderator_list_top_spenders_handler, crate::http_server::endpoints::moderation::user_spend_events::moderator_list_user_spend_events_handler::moderator_list_user_spend_events_handler, billing_component::stripe::http_endpoints::checkout::create::stripe_create_checkout_session_json_handler::stripe_create_checkout_session_json_handler, crate::http_server::endpoints::billing_fakeyou::list_active_user_subscriptions_handler::list_active_user_subscriptions_handler, @@ -656,6 +658,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux TopUserEntry, TopUsersWindow, ModeratorUserDailySpendsResponse, + ModeratorListTopSpendersResponse, + TopSpenderEntry, + TopSpendersWindow, ModeratorListUserSpendEventsResponse, UserSpendEventListEntry, UserDailySpendEntry, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/mod.rs index 58f04c668a..e44258a23b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/mod.rs @@ -14,6 +14,7 @@ pub mod user_stripe_data; pub mod wallet_ledger_entries; pub mod wallets; pub mod user_daily_spends; +pub mod top_spenders; pub mod user_spend_events; pub mod user_spend_summaries; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/top_spenders/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/top_spenders/mod.rs new file mode 100644 index 0000000000..76887a7d6b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/top_spenders/mod.rs @@ -0,0 +1 @@ +pub mod moderator_list_top_spenders_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/top_spenders/moderator_list_top_spenders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/top_spenders/moderator_list_top_spenders_handler.rs new file mode 100644 index 0000000000..0fba272354 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/top_spenders/moderator_list_top_spenders_handler.rs @@ -0,0 +1,93 @@ +use std::marker::PhantomData; +use std::sync::Arc; + +use actix_web::web::{Json, Query}; +use actix_web::{web, HttpRequest}; +use chrono::{Duration, Utc}; +use log::warn; + +use artcraft_api_defs::moderation::top_spenders::list::{ + ModeratorListTopSpendersQueryParams, ModeratorListTopSpendersResponse, TopSpenderEntry, TopSpendersWindow, +}; +use mysql_queries::queries::user_spend_events::list_top_spenders_for_window::{ + list_top_spenders_for_window, ListTopSpendersForWindowArgs, +}; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::user_lookup::user_session::require_moderator::require_moderator; +use crate::state::server_state::ServerState; + +const PAGE_SIZE: i64 = 100; + +const DEFAULT_WINDOW: TopSpendersWindow = TopSpendersWindow::ThirtyDays; + +/// Top spenders aggregated over a rolling time window, biggest net spend first. +/// Moderators only. +#[utoipa::path( + get, + tag = "Moderation", + path = "/v1/moderation/top_spenders/list", + params( + ModeratorListTopSpendersQueryParams, + ), + responses( + (status = 200, description = "Success", body = ModeratorListTopSpendersResponse), + (status = 400, description = "Invalid window"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Server error"), + ), +)] +pub async fn moderator_list_top_spenders_handler( + http_request: HttpRequest, + query: Query, + server_state: web::Data>, +) -> Result, CommonWebError> { + let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?; + + let window = query.window.unwrap_or(DEFAULT_WINDOW); + let window_duration = Duration::hours(window.as_hours()); + + let offset = query.offset.unwrap_or(0); + + let rows = list_top_spenders_for_window(ListTopSpendersForWindowArgs { + window_start: Utc::now() - window_duration, + maybe_payments_namespace: query.payments_namespace.as_deref(), + limit: PAGE_SIZE, + offset: offset.min(i64::MAX as u64) as i64, + mysql_executor: &server_state.mysql_pool, + phantom: PhantomData, + }) + .await + .map_err(|err| { + warn!("Failed to list top spenders: {:?}", err); + CommonWebError::from_error(err) + })?; + + let maybe_next_offset = if rows.len() as i64 == PAGE_SIZE { + Some(offset + PAGE_SIZE as u64) + } else { + None + }; + + let spenders = rows + .into_iter() + .map(|r| TopSpenderEntry { + user_token: r.user_token, + username: r.username, + display_name: r.display_name, + email_gravatar_hash: r.email_gravatar_hash, + gross_spend_usd_cents: r.gross_spend_usd_cents, + refund_usd_cents: r.refund_usd_cents, + net_spend_usd_cents: r.net_spend_usd_cents, + payment_count: r.payment_count, + credits_granted: r.credits_granted, + }) + .collect(); + + Ok(Json(ModeratorListTopSpendersResponse { + success: true, + window, + spenders, + maybe_next_offset, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/moderation_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/moderation_routes.rs index 77fb14595e..6de2e18a76 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/moderation_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/moderation_routes.rs @@ -53,6 +53,7 @@ use crate::http_server::endpoints::moderation::user_spend_summaries::moderator_g use crate::http_server::endpoints::moderation::user_spend_summaries::moderator_reengagement_list_handler::moderator_reengagement_list_handler; use crate::http_server::endpoints::moderation::user_spend_summaries::moderator_top_users_list_handler::moderator_top_users_list_handler; use crate::http_server::endpoints::moderation::user_daily_spends::moderator_user_daily_spends_handler::moderator_user_daily_spends_handler; +use crate::http_server::endpoints::moderation::top_spenders::moderator_list_top_spenders_handler::moderator_list_top_spenders_handler; use crate::http_server::endpoints::moderation::user_spend_events::moderator_list_user_spend_events_handler::moderator_list_user_spend_events_handler; pub fn add_moderator_routes (app: App) -> App @@ -81,6 +82,12 @@ pub fn add_moderator_routes (app: App) -> App .route(web::head().to(|| HttpResponse::Ok())) ) ) + .service(web::scope("/top_spenders") + .service(web::resource("/list") + .route(web::get().to(moderator_list_top_spenders_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + ) .service(web::scope("/user_spend_events") .service(web::resource("/list") .route(web::get().to(moderator_list_user_spend_events_handler)) diff --git a/frontend/apps/artcraft-dashboard/src/App.tsx b/frontend/apps/artcraft-dashboard/src/App.tsx index 02974c4f64..330cfdb1f6 100644 --- a/frontend/apps/artcraft-dashboard/src/App.tsx +++ b/frontend/apps/artcraft-dashboard/src/App.tsx @@ -26,6 +26,7 @@ import { DebugLogsAll } from "@/pages/DebugLogsAll"; import { UserDebugLogs } from "@/pages/UserDebugLogs"; import { DebugLogsSearch } from "@/pages/DebugLogsSearch"; import { SpendEvents } from "@/pages/SpendEvents"; +import { TopSpenders } from "@/pages/TopSpenders"; import { ReengagementList } from "@/pages/ReengagementList"; import { UserSpendSummary } from "@/pages/UserSpendSummary"; import { UserSpendHistory } from "@/pages/UserSpendHistory"; @@ -70,6 +71,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts b/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts index 2f9d552e88..c77f947756 100644 --- a/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts +++ b/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts @@ -85,6 +85,18 @@ export interface ModeratorUserLookupResponse { username_is_not_customized: boolean; } +export interface TopSpender { + user_token: string; + username: string; + display_name: string; + email_gravatar_hash: string; + gross_spend_usd_cents: number; + refund_usd_cents: number; + net_spend_usd_cents: number; + payment_count: number; + credits_granted: number; +} + export interface UserSpendEvent { token: string; payments_namespace: string; @@ -264,6 +276,43 @@ export class ModerationApi extends ApiManager { .catch((err) => ({ success: false, errorMessage: err.message })); } + // Top spenders over a rolling window (offset-paginated, biggest net spend first). + public async ListTopSpenders( + window: string, + offset?: number | null, + ): Promise< + ApiResponse<{ + spenders: TopSpender[]; + next_offset: number | null; + }> + > { + const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/top_spenders/list`; + return await this.get<{ + success: boolean; + spenders: TopSpender[]; + maybe_next_offset: number | null; + error_message?: string; + }>({ + endpoint, + query: { + window, + offset: offset ?? undefined, + }, + }) + .then((response) => ({ + success: response.success, + data: { + spenders: response.spenders || [], + next_offset: response.maybe_next_offset ?? null, + }, + errorMessage: response.error_message, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + // Spend events list (offset-paginated, newest payment first). public async ListUserSpendEvents( offset?: number | null, diff --git a/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx b/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx index 67d348dc6b..e742ac759d 100644 --- a/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx +++ b/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { + IconTrophy, IconBrandStripe, IconBriefcase, IconBug, @@ -63,6 +64,7 @@ const data = { { label: "Business Growth", items: [ + { title: "Top Spenders", url: "/top-spenders", icon: IconTrophy }, { title: "Spend Events", url: "/spend-events", icon: IconCoin }, { title: "Reengagement List", url: "/reengagement-list", icon: IconHeartHandshake }, ], diff --git a/frontend/apps/artcraft-dashboard/src/pages/SpendEvents.tsx b/frontend/apps/artcraft-dashboard/src/pages/SpendEvents.tsx index 4ff9c7e7d1..c0d9e26fe6 100644 --- a/frontend/apps/artcraft-dashboard/src/pages/SpendEvents.tsx +++ b/frontend/apps/artcraft-dashboard/src/pages/SpendEvents.tsx @@ -27,6 +27,25 @@ import { usePageTitle } from "@/hooks/usePageTitle"; const COLUMN_COUNT = 8; +// Distinct pill colors per payment event type (see enums payment_event_type.rs). +// Subscription lifecycle stays in the cool blue/green family; money-out events +// are warm (rose/red); staff actions are fuchsia so they stand out. +const EVENT_TYPE_BADGE_CLASSES: Record = { + subscription_initial: "bg-green-500/10 text-green-300", + subscription_renewal: "bg-lime-500/10 text-lime-300", + subscription_proration_upgrade: "bg-cyan-500/10 text-cyan-300", + subscription_proration_downgrade: "bg-orange-500/10 text-orange-300", + subscription_monthly_refill: "bg-teal-500/10 text-teal-300", + credit_pack_purchase: "bg-indigo-500/10 text-indigo-300", + refund: "bg-rose-500/10 text-rose-300", + chargeback: "bg-red-500/15 text-red-400", + manual_adjustment: "bg-fuchsia-500/10 text-fuchsia-300", +}; + +// Neutral fallback for unknown/future event types, so indigo stays unique to +// credit_pack_purchase. +const DEFAULT_EVENT_TYPE_BADGE_CLASSES = "bg-slate-500/10 text-slate-300"; + const usdFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", @@ -263,7 +282,10 @@ export function SpendEvents() { {event.event_type} diff --git a/frontend/apps/artcraft-dashboard/src/pages/TopSpenders.tsx b/frontend/apps/artcraft-dashboard/src/pages/TopSpenders.tsx new file mode 100644 index 0000000000..c97211f0ce --- /dev/null +++ b/frontend/apps/artcraft-dashboard/src/pages/TopSpenders.tsx @@ -0,0 +1,331 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { Link } from "react-router-dom"; +import { ModerationApi } from "@/api/ModerationApi"; +import type { TopSpender } from "@/api/ModerationApi"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Table, + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, +} from "@/components/ui/table"; +import { useTableHeight } from "@/hooks/useTableHeight"; +import { + IconAlertCircle, + IconChartLine, + IconReceipt2, + IconRefresh, + IconLoader2, + IconTrophy, +} from "@tabler/icons-react"; +import { usePageTitle } from "@/hooks/usePageTitle"; + +const COLUMN_COUNT = 7; + +const WINDOWS = [ + { value: "24h", label: "24 hours" }, + { value: "7d", label: "7 days" }, + { value: "14d", label: "14 days" }, + { value: "30d", label: "30 days" }, + { value: "60d", label: "60 days" }, + { value: "90d", label: "90 days" }, + { value: "180d", label: "180 days" }, + { value: "365d", label: "365 days" }, +]; + +const DEFAULT_WINDOW = "30d"; + +const usdFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}); + +function formatUsd(cents: number): string { + return usdFormatter.format(cents / 100); +} + +export function TopSpenders() { + usePageTitle("Top Spenders"); + const [spenders, setSpenders] = useState([]); + const [window, setWindow] = useState(DEFAULT_WINDOW); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [nextOffset, setNextOffset] = useState(null); + + const cancelledRef = useRef(false); + const observerRef = useRef(null); + const sentinelRef = useRef(null); + + const loadData = async ( + timeWindow: string, + offset?: number | null, + append = false, + ) => { + if (!append) { + setIsLoading(true); + setError(null); + } else { + setIsLoadingMore(true); + } + + try { + const modApi = new ModerationApi(); + const resp = await modApi.ListTopSpenders(timeWindow, offset); + + if (cancelledRef.current) return; + + if (resp.success && resp.data) { + setSpenders((prev) => + append ? [...prev, ...resp.data!.spenders] : resp.data!.spenders, + ); + setNextOffset(resp.data.next_offset); + } else { + setError(resp.errorMessage || "Failed to load top spenders"); + } + } catch (err: any) { + if (!cancelledRef.current) + setError(err.message || "Failed to load top spenders"); + } finally { + if (!cancelledRef.current) { + setIsLoading(false); + setIsLoadingMore(false); + } + } + }; + + const handleWindowChange = (value: string) => { + setWindow(value); + setSpenders([]); + setNextOffset(null); + loadData(value); + }; + + const handleRefresh = () => { + setSpenders([]); + setNextOffset(null); + loadData(window); + }; + + useEffect(() => { + cancelledRef.current = false; + loadData(DEFAULT_WINDOW); + return () => { + cancelledRef.current = true; + }; + }, []); + + const loadMore = useCallback(() => { + if (!isLoadingMore && nextOffset != null) { + loadData(window, nextOffset, true); + } + }, [window, nextOffset, isLoadingMore]); + + useEffect(() => { + if (observerRef.current) observerRef.current.disconnect(); + + observerRef.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) loadMore(); + }, + { threshold: 0.1 }, + ); + + if (sentinelRef.current) { + observerRef.current.observe(sentinelRef.current); + } + + return () => observerRef.current?.disconnect(); + }, [loadMore]); + + const { ref: tableRef, height: tableHeight } = useTableHeight(); + + return ( +
+
+
+

+ + Top Spenders +

+

+ Users ranked by net spend over the selected window +

+
+ +
+ +
+ {WINDOWS.map((w) => ( + + ))} +
+ + {error && ( + + + {error} + + )} + +
+
+ {isLoading ? ( + + + + # + User + Net Spend + Gross + Refunds + Payments + Credits + + + + {Array.from({ length: 12 }).map((_, i) => ( + + {Array.from({ length: COLUMN_COUNT }).map((__, j) => ( + + + + ))} + + ))} + +
+ ) : spenders.length === 0 ? ( +
+ +

+ No spend in this window. +

+
+ ) : ( + + + + # + User + Net Spend + Gross + Refunds + Payments + Credits + + + + {spenders.map((spender, index) => ( + + + {index + 1} + + +
+ + @{spender.username} + + + + + + | + + + + +
+
+ + {formatUsd(spender.net_spend_usd_cents)} + + + {formatUsd(spender.gross_spend_usd_cents)} + + + {spender.refund_usd_cents > 0 ? ( + + {formatUsd(spender.refund_usd_cents)} + + ) : ( + — + )} + + + {spender.payment_count.toLocaleString()} + + + {spender.credits_granted > 0 ? ( + spender.credits_granted.toLocaleString() + ) : ( + — + )} + +
+ ))} + {nextOffset != null && ( + + + {isLoadingMore ? ( +
+ + Loading more... +
+ ) : ( + + Scroll for more + + )} +
+
+ )} +
+
+ )} +
+
+
+ ); +}