mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-24 02:29:32 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9faa8521cd | ||
|
|
a71d2ebde2 | ||
|
|
b91ea1cdc3 |
+113
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TopSpendersWindow>,
|
||||
/// Row offset for pagination (page size is fixed at 100). Pass `next_offset` from the previous page.
|
||||
pub offset: Option<u64>,
|
||||
/// Optional namespace filter (e.g. `artcraft`). Omit to return all namespaces.
|
||||
pub payments_namespace: Option<String>,
|
||||
}
|
||||
|
||||
#[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<TopSpenderEntry>,
|
||||
/// Offset for the next page, or `None` if this was the last page.
|
||||
pub maybe_next_offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod list;
|
||||
+78
@@ -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<Utc>,
|
||||
/// 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<Vec<TopSpenderForWindow>, 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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod moderator_list_top_spenders_handler;
|
||||
+93
@@ -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<ModeratorListTopSpendersQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorListTopSpendersResponse>, 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,
|
||||
}))
|
||||
}
|
||||
+7
@@ -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<T, B> (app: App<T>) -> App<T>
|
||||
@@ -81,6 +82,12 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
|
||||
.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))
|
||||
|
||||
@@ -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() {
|
||||
<Route path="stripe-lookup" element={<StripeLookup />} />
|
||||
<Route path="user-signups" element={<UserSignups />} />
|
||||
<Route path="subscriber-signups" element={<SubscriberSignups />} />
|
||||
<Route path="top-spenders" element={<TopSpenders />} />
|
||||
<Route path="spend-events" element={<SpendEvents />} />
|
||||
<Route path="reengagement-list" element={<ReengagementList />} />
|
||||
<Route path="impersonation" element={<Impersonation />} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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() {
|
||||
<TableCell className="text-xs">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] font-mono bg-indigo-500/10 text-indigo-300 border-transparent"
|
||||
className={`text-[10px] font-mono border-transparent ${
|
||||
EVENT_TYPE_BADGE_CLASSES[event.event_type] ??
|
||||
DEFAULT_EVENT_TYPE_BADGE_CLASSES
|
||||
}`}
|
||||
>
|
||||
{event.event_type}
|
||||
</Badge>
|
||||
|
||||
@@ -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<TopSpender[]>([]);
|
||||
const [window, setWindow] = useState(DEFAULT_WINDOW);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nextOffset, setNextOffset] = useState<number | null>(null);
|
||||
|
||||
const cancelledRef = useRef(false);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const sentinelRef = useRef<HTMLTableRowElement | null>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
|
||||
<IconTrophy className="size-6 text-muted-foreground" />
|
||||
Top Spenders
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Users ranked by net spend over the selected window
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconRefresh className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{WINDOWS.map((w) => (
|
||||
<Button
|
||||
key={w.value}
|
||||
variant={window === w.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() => handleWindowChange(w.value)}
|
||||
disabled={isLoading && window === w.value}
|
||||
>
|
||||
{w.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="max-w-xl">
|
||||
<IconAlertCircle className="size-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div ref={tableRef}>
|
||||
{isLoading ? (
|
||||
<Table containerClassName="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent bg-muted/30">
|
||||
<TableHead className="text-xs w-12">#</TableHead>
|
||||
<TableHead className="text-xs">User</TableHead>
|
||||
<TableHead className="text-xs text-right">Net Spend</TableHead>
|
||||
<TableHead className="text-xs text-right">Gross</TableHead>
|
||||
<TableHead className="text-xs text-right">Refunds</TableHead>
|
||||
<TableHead className="text-xs text-right">Payments</TableHead>
|
||||
<TableHead className="text-xs text-right">Credits</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((__, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : spenders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
|
||||
<IconTrophy className="size-10 text-muted-foreground mb-4 opacity-20" />
|
||||
<p className="text-muted-foreground">
|
||||
No spend in this window.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
containerClassName="rounded-xl border bg-card shadow-sm min-h-[200px]"
|
||||
containerStyle={{ maxHeight: tableHeight ?? "60vh" }}
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-card">
|
||||
<TableRow className="hover:bg-transparent bg-muted/30">
|
||||
<TableHead className="text-xs w-12">#</TableHead>
|
||||
<TableHead className="text-xs">User</TableHead>
|
||||
<TableHead className="text-xs text-right">Net Spend</TableHead>
|
||||
<TableHead className="text-xs text-right">Gross</TableHead>
|
||||
<TableHead className="text-xs text-right">Refunds</TableHead>
|
||||
<TableHead className="text-xs text-right">Payments</TableHead>
|
||||
<TableHead className="text-xs text-right">Credits</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{spenders.map((spender, index) => (
|
||||
<TableRow key={spender.user_token} className="group">
|
||||
<TableCell className="text-sm text-muted-foreground tabular-nums">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/user/profile/${spender.username}`}
|
||||
className="hover:underline text-foreground"
|
||||
>
|
||||
@{spender.username}
|
||||
</Link>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Link
|
||||
to={`/user/spend-summary/${spender.username}`}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Spend summary"
|
||||
>
|
||||
<IconChartLine className="size-4" />
|
||||
</Link>
|
||||
<span className="text-muted-foreground/30">|</span>
|
||||
<Link
|
||||
to={`/user/spend-history/${spender.username}`}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Spend history"
|
||||
>
|
||||
<IconReceipt2 className="size-4" />
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-sm text-right tabular-nums whitespace-nowrap font-medium ${
|
||||
spender.net_spend_usd_cents < 0
|
||||
? "text-red-400"
|
||||
: "text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{formatUsd(spender.net_spend_usd_cents)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-right tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{formatUsd(spender.gross_spend_usd_cents)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-right tabular-nums whitespace-nowrap">
|
||||
{spender.refund_usd_cents > 0 ? (
|
||||
<span className="text-red-400">
|
||||
{formatUsd(spender.refund_usd_cents)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/30">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
|
||||
{spender.payment_count.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
|
||||
{spender.credits_granted > 0 ? (
|
||||
spender.credits_granted.toLocaleString()
|
||||
) : (
|
||||
<span className="text-muted-foreground/30">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{nextOffset != null && (
|
||||
<TableRow ref={sentinelRef}>
|
||||
<TableCell colSpan={COLUMN_COUNT} className="text-center py-4">
|
||||
{isLoadingMore ? (
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
Loading more...
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50">
|
||||
Scroll for more
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user