mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-05 05:48:06 -05:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbae71fa41 | ||
|
|
80dce649ac | ||
|
|
378efa8bea | ||
|
|
23b152f5c3 | ||
|
|
7fcf74e4c3 | ||
|
|
4444c3bdfa | ||
|
|
062be61694 | ||
|
|
9a613c453d | ||
|
|
e60fbe80ae | ||
|
|
18582765f5 | ||
|
|
6f4ad8f87a |
-58
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n payment_occurred_at AS `payment_occurred_at!: DateTime<Utc>`,\n amount_usd_cents,\n event_type AS `event_type!: PaymentEventType`,\n maybe_credits_granted AS `maybe_credits_granted: u32`\nFROM user_spend_events\nWHERE maybe_user_token = ?\n AND payments_namespace = ?\n AND is_production = TRUE\nORDER BY payment_occurred_at ASC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "payment_occurred_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "amount_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "event_type!: PaymentEventType",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_credits_granted: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1c997019b7ed18059bc08d857918c3ce3f5e6ed56d358894cbc496d345566519"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO user_spend_summaries\nSET\n payments_namespace = ?,\n user_token = ?,\n lifetime_gross_spend_usd_cents = ?,\n lifetime_subscription_spend_usd_cents = ?,\n lifetime_credits_spend_usd_cents = ?,\n lifetime_refund_usd_cents = ?,\n lifetime_net_spend_usd_cents = ?,\n lifetime_payment_count = ?,\n lifetime_refund_count = ?,\n first_payment_at = ?,\n first_spend_usd_cents = ?,\n last_payment_at = ?,\n last_spend_usd_cents = ?,\n days_since_first_payment = ?,\n days_since_last_payment = ?,\n net_spend_7d_usd_cents = ?,\n net_spend_prev_7d_usd_cents = ?,\n net_spend_14d_usd_cents = ?,\n net_spend_prev_14d_usd_cents = ?,\n net_spend_30d_usd_cents = ?,\n net_spend_prev_30d_usd_cents = ?,\n net_spend_60d_usd_cents = ?,\n net_spend_90d_usd_cents = ?,\n net_spend_this_year_usd_cents = ?,\n avg_weekly_net_spend_4w_usd_cents = ?,\n avg_weekly_net_spend_12w_usd_cents = ?,\n active_weeks_in_last_4 = ?,\n active_weeks_in_last_8 = ?,\n active_weeks_in_last_12 = ?,\n active_weeks_in_last_24 = ?,\n active_weeks_in_last_52 = ?,\n consecutive_active_weeks = ?,\n consecutive_inactive_weeks = ?,\n weeks_since_last_spend = ?,\n is_active_subscriber = ?,\n maybe_subscription_interval = ?,\n maybe_reengagement_score = ?\nON DUPLICATE KEY UPDATE\n lifetime_gross_spend_usd_cents = VALUES(lifetime_gross_spend_usd_cents),\n lifetime_subscription_spend_usd_cents = VALUES(lifetime_subscription_spend_usd_cents),\n lifetime_credits_spend_usd_cents = VALUES(lifetime_credits_spend_usd_cents),\n lifetime_refund_usd_cents = VALUES(lifetime_refund_usd_cents),\n lifetime_net_spend_usd_cents = VALUES(lifetime_net_spend_usd_cents),\n lifetime_payment_count = VALUES(lifetime_payment_count),\n lifetime_refund_count = VALUES(lifetime_refund_count),\n first_payment_at = VALUES(first_payment_at),\n first_spend_usd_cents = VALUES(first_spend_usd_cents),\n last_payment_at = VALUES(last_payment_at),\n last_spend_usd_cents = VALUES(last_spend_usd_cents),\n days_since_first_payment = VALUES(days_since_first_payment),\n days_since_last_payment = VALUES(days_since_last_payment),\n net_spend_7d_usd_cents = VALUES(net_spend_7d_usd_cents),\n net_spend_prev_7d_usd_cents = VALUES(net_spend_prev_7d_usd_cents),\n net_spend_14d_usd_cents = VALUES(net_spend_14d_usd_cents),\n net_spend_prev_14d_usd_cents = VALUES(net_spend_prev_14d_usd_cents),\n net_spend_30d_usd_cents = VALUES(net_spend_30d_usd_cents),\n net_spend_prev_30d_usd_cents = VALUES(net_spend_prev_30d_usd_cents),\n net_spend_60d_usd_cents = VALUES(net_spend_60d_usd_cents),\n net_spend_90d_usd_cents = VALUES(net_spend_90d_usd_cents),\n net_spend_this_year_usd_cents = VALUES(net_spend_this_year_usd_cents),\n avg_weekly_net_spend_4w_usd_cents = VALUES(avg_weekly_net_spend_4w_usd_cents),\n avg_weekly_net_spend_12w_usd_cents = VALUES(avg_weekly_net_spend_12w_usd_cents),\n active_weeks_in_last_4 = VALUES(active_weeks_in_last_4),\n active_weeks_in_last_8 = VALUES(active_weeks_in_last_8),\n active_weeks_in_last_12 = VALUES(active_weeks_in_last_12),\n active_weeks_in_last_24 = VALUES(active_weeks_in_last_24),\n active_weeks_in_last_52 = VALUES(active_weeks_in_last_52),\n consecutive_active_weeks = VALUES(consecutive_active_weeks),\n consecutive_inactive_weeks = VALUES(consecutive_inactive_weeks),\n weeks_since_last_spend = VALUES(weeks_since_last_spend),\n is_active_subscriber = VALUES(is_active_subscriber),\n maybe_subscription_interval = VALUES(maybe_subscription_interval),\n maybe_reengagement_score = VALUES(maybe_reengagement_score)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 37
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "41cf2f8d64aa36fb930cc1bf8a46d37e0987b743438b44e1c55859fc6b6ee6a0"
|
||||
}
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n s.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 s.maybe_reengagement_score AS `reengagement_score!: u32`,\n s.lifetime_net_spend_usd_cents,\n s.last_payment_at AS `maybe_last_payment_at: DateTime<Utc>`,\n s.days_since_last_payment AS `maybe_days_since_last_payment: u32`,\n s.weeks_since_last_spend AS `maybe_weeks_since_last_spend: u32`,\n s.is_active_subscriber AS `is_active_subscriber: bool`\nFROM user_spend_summaries s\nJOIN users u ON u.token = s.user_token\nWHERE s.payments_namespace = ?\n AND s.maybe_reengagement_score > 0\nORDER BY s.maybe_reengagement_score DESC, s.id ASC\nLIMIT ? OFFSET ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"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": "reengagement_score!: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "lifetime_net_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_last_payment_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "maybe_days_since_last_payment: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "maybe_weeks_since_last_spend: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "is_active_subscriber: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5b1954301fc16703a04e2f63fd85c28bf2283bc1ecb5624107686dad0dfe364f"
|
||||
}
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n payments_namespace AS `payments_namespace!: PaymentsNamespace`,\n user_token AS `user_token!: UserToken`,\n spend_date AS `spend_date!: NaiveDate`,\n subscription_spend_usd_cents,\n credits_spend_usd_cents,\n gross_spend_usd_cents,\n refund_usd_cents,\n net_spend_usd_cents,\n payment_count,\n credits_granted,\n created_at AS `created_at: DateTime<Utc>`,\n updated_at AS `updated_at: DateTime<Utc>`\nFROM user_daily_spends\nWHERE user_token = ?\n AND payments_namespace = ?\nORDER BY spend_date DESC\nLIMIT ? OFFSET ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "payments_namespace!: PaymentsNamespace",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "spend_date!: NaiveDate",
|
||||
"type_info": {
|
||||
"type": "Date",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "subscription_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "credits_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "gross_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "refund_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "net_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "payment_count",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "credits_granted",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "created_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "77dca63cffdd18957cbc4be6b7d982a2599a2c06df07f8e34501a032cd688ead"
|
||||
}
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n maybe_user_token AS `user_token!: UserToken`,\n payments_namespace AS `payments_namespace!: PaymentsNamespace`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0\n AND event_type IN ('subscription_initial', 'subscription_renewal', 'subscription_proration_upgrade')\n THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `subscription_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0 AND event_type = 'credit_pack_purchase'\n THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `credits_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN amount_usd_cents < 0 THEN -amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`,\n CAST(SUM(amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`,\n CAST(COALESCE(SUM(maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64`\nFROM user_spend_events\nWHERE maybe_user_token IS NOT NULL\n AND is_production = TRUE\n AND payment_occurred_at >= ?\n AND payment_occurred_at < ?\nGROUP BY maybe_user_token, payments_namespace\nHAVING SUM(CASE WHEN amount_usd_cents <> 0 THEN 1 ELSE 0 END) > 0\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": "payments_namespace!: PaymentsNamespace",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "subscription_spend_usd_cents!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "credits_spend_usd_cents!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "83fc05a3594b379482745f851cd5a22786adde9968dd70490c91100cf21f8023"
|
||||
}
|
||||
-443
@@ -1,443 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n payments_namespace AS `payments_namespace!: PaymentsNamespace`,\n user_token AS `user_token!: UserToken`,\n lifetime_gross_spend_usd_cents,\n lifetime_subscription_spend_usd_cents,\n lifetime_credits_spend_usd_cents,\n lifetime_refund_usd_cents,\n lifetime_net_spend_usd_cents,\n lifetime_payment_count,\n lifetime_refund_count,\n first_payment_at AS `maybe_first_payment_at: DateTime<Utc>`,\n first_spend_usd_cents,\n last_payment_at AS `maybe_last_payment_at: DateTime<Utc>`,\n last_spend_usd_cents,\n days_since_first_payment AS `maybe_days_since_first_payment: u32`,\n days_since_last_payment AS `maybe_days_since_last_payment: u32`,\n net_spend_7d_usd_cents,\n net_spend_prev_7d_usd_cents,\n net_spend_14d_usd_cents,\n net_spend_prev_14d_usd_cents,\n net_spend_30d_usd_cents,\n net_spend_prev_30d_usd_cents,\n net_spend_60d_usd_cents,\n net_spend_90d_usd_cents,\n net_spend_this_year_usd_cents,\n avg_weekly_net_spend_4w_usd_cents,\n avg_weekly_net_spend_12w_usd_cents,\n active_weeks_in_last_4,\n active_weeks_in_last_8,\n active_weeks_in_last_12,\n active_weeks_in_last_24,\n active_weeks_in_last_52,\n consecutive_active_weeks,\n consecutive_inactive_weeks,\n weeks_since_last_spend AS `maybe_weeks_since_last_spend: u32`,\n is_active_subscriber AS `is_active_subscriber: bool`,\n maybe_subscription_interval,\n maybe_reengagement_score AS `maybe_reengagement_score: u32`,\n created_at AS `created_at: DateTime<Utc>`,\n updated_at AS `updated_at: DateTime<Utc>`\nFROM user_spend_summaries\nWHERE payments_namespace = ?\n AND user_token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "payments_namespace!: PaymentsNamespace",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "lifetime_gross_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "lifetime_subscription_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "lifetime_credits_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "lifetime_refund_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "lifetime_net_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "lifetime_payment_count",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "lifetime_refund_count",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_first_payment_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "first_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "maybe_last_payment_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "last_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "maybe_days_since_first_payment: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "maybe_days_since_last_payment: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "net_spend_7d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "net_spend_prev_7d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "net_spend_14d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "net_spend_prev_14d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "net_spend_30d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "net_spend_prev_30d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "net_spend_60d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "net_spend_90d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "net_spend_this_year_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "avg_weekly_net_spend_4w_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "avg_weekly_net_spend_12w_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 26,
|
||||
"name": "active_weeks_in_last_4",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 27,
|
||||
"name": "active_weeks_in_last_8",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 28,
|
||||
"name": "active_weeks_in_last_12",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 29,
|
||||
"name": "active_weeks_in_last_24",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "active_weeks_in_last_52",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 31,
|
||||
"name": "consecutive_active_weeks",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 32,
|
||||
"name": "consecutive_inactive_weeks",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 33,
|
||||
"name": "maybe_weeks_since_last_spend: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 34,
|
||||
"name": "is_active_subscriber: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 35,
|
||||
"name": "maybe_subscription_interval",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 36,
|
||||
"name": "maybe_reengagement_score: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 37,
|
||||
"name": "created_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 38,
|
||||
"name": "updated_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9715e4dc2ec6e596fdb91ecc50a2b80f63494bbc0c9df6c0a57ede2360716869"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO user_daily_spends\nSET\n user_token = ?,\n payments_namespace = ?,\n spend_date = ?,\n subscription_spend_usd_cents = ?,\n credits_spend_usd_cents = ?,\n gross_spend_usd_cents = ?,\n refund_usd_cents = ?,\n net_spend_usd_cents = ?,\n payment_count = ?,\n credits_granted = ?\nON DUPLICATE KEY UPDATE\n subscription_spend_usd_cents = VALUES(subscription_spend_usd_cents),\n credits_spend_usd_cents = VALUES(credits_spend_usd_cents),\n gross_spend_usd_cents = VALUES(gross_spend_usd_cents),\n refund_usd_cents = VALUES(refund_usd_cents),\n net_spend_usd_cents = VALUES(net_spend_usd_cents),\n payment_count = VALUES(payment_count),\n credits_granted = VALUES(credits_granted)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b7a5fea76da70c03b8744c985940a5bcdabdb3316aa407c5ca883bca863ee633"
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT DISTINCT maybe_user_token AS `user_token!: UserToken`\nFROM user_spend_events\nWHERE maybe_user_token IS NOT NULL\n AND is_production = TRUE\n AND payments_namespace = ?\n AND maybe_user_token > ?\nORDER BY maybe_user_token\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bcd07050d5557bf59d6b2691478763b5b067447d295b002f6e766209ea077f42"
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT DISTINCT payments_namespace AS `payments_namespace!: PaymentsNamespace`\nFROM user_spend_events\nWHERE maybe_user_token IS NOT NULL\n AND is_production = TRUE\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "payments_namespace!: PaymentsNamespace",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "cf8c702126bba73cd3abc2ed7202962b6da773460505222a75408dab1d26b70f"
|
||||
}
|
||||
-212
@@ -1,212 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n e.token AS `token!: UserSpendEventToken`,\n e.payments_namespace AS `payments_namespace!`,\n e.maybe_user_token AS `maybe_user_token: UserToken`,\n u.username AS `maybe_username?`,\n u.display_name AS `maybe_display_name?`,\n u.email_gravatar_hash AS `maybe_email_gravatar_hash?`,\n e.event_type AS `event_type!`,\n e.amount_usd_cents,\n e.maybe_credits_granted AS `maybe_credits_granted: u32`,\n e.payment_source AS `payment_source!`,\n e.source_object_id AS `maybe_source_object_id`,\n e.maybe_stripe_invoice_id,\n e.maybe_stripe_payment_intent_id,\n e.maybe_stripe_charge_id,\n e.maybe_stripe_customer_id,\n e.is_production AS `is_production: bool`,\n e.payment_occurred_at AS `payment_occurred_at!: DateTime<Utc>`,\n e.created_at AS `created_at!: DateTime<Utc>`\nFROM user_spend_events e\nLEFT JOIN users u ON u.token = e.maybe_user_token\nWHERE (? IS NULL OR e.payments_namespace = ?)\nORDER BY e.payment_occurred_at DESC, e.id DESC\nLIMIT ? OFFSET ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token!: UserSpendEventToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "payments_namespace!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "maybe_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_username?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_display_name?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "maybe_email_gravatar_hash?",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "event_type!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "amount_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "maybe_credits_granted: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "payment_source!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_source_object_id",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "maybe_stripe_invoice_id",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "maybe_stripe_payment_intent_id",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "maybe_stripe_charge_id",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "maybe_stripe_customer_id",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "is_production: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "payment_occurred_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "created_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d5d3a59ea4885868b26168cd305f6168a1e62fe9f8fa663063591335c645b927"
|
||||
}
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n s.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 s.lifetime_net_spend_usd_cents,\n s.net_spend_7d_usd_cents,\n s.net_spend_14d_usd_cents,\n s.net_spend_30d_usd_cents,\n s.net_spend_60d_usd_cents,\n s.net_spend_90d_usd_cents,\n s.net_spend_this_year_usd_cents,\n s.is_active_subscriber AS `is_active_subscriber: bool`,\n s.maybe_reengagement_score AS `maybe_reengagement_score: u32`\nFROM user_spend_summaries s\nJOIN users u ON u.token = s.user_token\nWHERE s.payments_namespace = ?\nORDER BY\n (CASE ?\n WHEN '7d' THEN s.net_spend_7d_usd_cents\n WHEN '14d' THEN s.net_spend_14d_usd_cents\n WHEN '30d' THEN s.net_spend_30d_usd_cents\n WHEN '60d' THEN s.net_spend_60d_usd_cents\n WHEN '90d' THEN s.net_spend_90d_usd_cents\n WHEN '1y' THEN s.net_spend_this_year_usd_cents\n ELSE CAST(s.lifetime_net_spend_usd_cents AS SIGNED)\n END) DESC,\n s.id ASC\nLIMIT ? OFFSET ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"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": "lifetime_net_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "net_spend_7d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "net_spend_14d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "net_spend_30d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "net_spend_60d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "net_spend_90d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "net_spend_this_year_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "is_active_subscriber: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "maybe_reengagement_score: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f6db2f3bf8695fa8431db209576328154b8ac20aa6e899739095b6206bb38944"
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n maybe_stripe_subscription_status,\n maybe_stripe_recurring_interval,\n subscription_expires_at AS `subscription_expires_at!: DateTime<Utc>`\nFROM user_subscriptions\nWHERE user_token = ?\n AND subscription_namespace = ?\nORDER BY subscription_expires_at DESC\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "maybe_stripe_subscription_status",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "maybe_stripe_recurring_interval",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "subscription_expires_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f74615faa4321ec4d583829997e12cb8ae152afefd2574385c120c63278893d2"
|
||||
}
|
||||
Generated
-30
@@ -13704,36 +13704,6 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "user-spend-analytics-job"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"actix-web",
|
||||
"actix_helpers",
|
||||
"anyhow",
|
||||
"bootstrap",
|
||||
"chrono",
|
||||
"concurrency",
|
||||
"easyenv",
|
||||
"enums",
|
||||
"errors",
|
||||
"jobs_common",
|
||||
"log",
|
||||
"mysql_queries",
|
||||
"pager",
|
||||
"rootly_client",
|
||||
"rootly_config",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"server_environment",
|
||||
"shared_env_var_config",
|
||||
"sqlx",
|
||||
"tokens",
|
||||
"tokio",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "user_input_common"
|
||||
version = "0.0.1"
|
||||
|
||||
@@ -136,7 +136,6 @@ members = [
|
||||
"crates/service/job/grok_api_job",
|
||||
"crates/service/job/seedance2_pro_job",
|
||||
"crates/service/job/video_thumbnail_job",
|
||||
"crates/service/job/user_spend_analytics_job",
|
||||
"crates/service/job/worldlabs_job",
|
||||
"crates/service/web/dummy_service",
|
||||
"crates/service/web/storyteller_web",
|
||||
|
||||
@@ -138,7 +138,6 @@ RUN SQLX_OFFLINE=true \
|
||||
$HOME/.cargo/bin/cargo build \
|
||||
--release \
|
||||
--bin storyteller-web \
|
||||
--bin user-spend-analytics-job \
|
||||
--bin seedance2-pro-job \
|
||||
--bin worldlabs-job \
|
||||
--bin video-thumbnail-job \
|
||||
@@ -191,7 +190,6 @@ COPY --from=builder /tmp/target/release/analytics-job /
|
||||
COPY --from=builder /tmp/target/release/email-sender-job /
|
||||
COPY --from=builder /tmp/target/release/es-update-job /
|
||||
COPY --from=builder /tmp/target/release/grok-api-job /
|
||||
COPY --from=builder /tmp/target/release/user-spend-analytics-job /
|
||||
|
||||
# Legacy apps:
|
||||
# COPY --from=builder /tmp/target/release/tts-download-job /
|
||||
|
||||
@@ -5,6 +5,3 @@ pub mod user_referrals;
|
||||
pub mod user_stripe_data;
|
||||
pub mod wallet_ledger_entries;
|
||||
pub mod wallets;
|
||||
pub mod user_daily_spends;
|
||||
pub mod user_spend_summaries;
|
||||
pub mod user_spend_events;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub mod user_daily_spends_list;
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::users::UserToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
pub const MODERATOR_USER_DAILY_SPENDS_PATH: &str =
|
||||
"/v1/moderation/user_daily_spends/user/{user_token}";
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct ModeratorUserDailySpendsQueryParams {
|
||||
/// Page size (default 200, max 5000).
|
||||
pub limit: Option<u32>,
|
||||
/// Row offset for pagination. Pass `next_offset` from the previous page.
|
||||
pub offset: Option<u64>,
|
||||
/// Payments namespace. Defaults to `artcraft`.
|
||||
pub payments_namespace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModeratorUserDailySpendsResponse {
|
||||
pub success: bool,
|
||||
pub user_token: UserToken,
|
||||
pub records: Vec<UserDailySpendEntry>,
|
||||
/// Offset for the next page, or `None` if this was the last page.
|
||||
pub maybe_next_offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserDailySpendEntry {
|
||||
pub payments_namespace: String,
|
||||
pub spend_date: NaiveDate,
|
||||
pub subscription_spend_usd_cents: u64,
|
||||
pub credits_spend_usd_cents: u64,
|
||||
pub gross_spend_usd_cents: u64,
|
||||
pub refund_usd_cents: u64,
|
||||
pub net_spend_usd_cents: i64,
|
||||
pub payment_count: u32,
|
||||
pub credits_granted: u64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::user_spend_events::UserSpendEventToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
pub const MODERATOR_LIST_USER_SPEND_EVENTS_PATH: &str =
|
||||
"/v1/moderation/user_spend_events/list";
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct ModeratorListUserSpendEventsQueryParams {
|
||||
/// Row offset for pagination (page size is fixed at 200). 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 ModeratorListUserSpendEventsResponse {
|
||||
pub success: bool,
|
||||
pub events: Vec<UserSpendEventListEntry>,
|
||||
/// Offset for the next page, or `None` if this was the last page.
|
||||
pub maybe_next_offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserSpendEventListEntry {
|
||||
pub token: UserSpendEventToken,
|
||||
pub payments_namespace: String,
|
||||
pub maybe_user_token: Option<UserToken>,
|
||||
pub maybe_username: Option<String>,
|
||||
pub maybe_display_name: Option<String>,
|
||||
pub maybe_email_gravatar_hash: Option<String>,
|
||||
pub event_type: String,
|
||||
pub amount_usd_cents: i64,
|
||||
pub maybe_credits_granted: Option<u32>,
|
||||
pub payment_source: String,
|
||||
pub maybe_source_object_id: Option<String>,
|
||||
pub maybe_stripe_invoice_id: Option<String>,
|
||||
pub maybe_stripe_payment_intent_id: Option<String>,
|
||||
pub maybe_stripe_charge_id: Option<String>,
|
||||
pub maybe_stripe_customer_id: Option<String>,
|
||||
pub is_production: bool,
|
||||
pub payment_occurred_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod list;
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::users::UserToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
pub const MODERATOR_GET_USER_SPEND_SUMMARY_PATH: &str =
|
||||
"/v1/moderation/user_spend_summaries/summary/{user_token}";
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct ModeratorGetUserSpendSummaryQueryParams {
|
||||
/// Payments namespace. Defaults to `artcraft`.
|
||||
pub payments_namespace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModeratorGetUserSpendSummaryResponse {
|
||||
pub success: bool,
|
||||
/// `None` if the user has no spend summary row.
|
||||
pub maybe_summary: Option<UserSpendSummaryView>,
|
||||
}
|
||||
|
||||
/// The full `user_spend_summaries` record for a user.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserSpendSummaryView {
|
||||
pub payments_namespace: String,
|
||||
pub user_token: UserToken,
|
||||
|
||||
pub lifetime_gross_spend_usd_cents: u64,
|
||||
pub lifetime_subscription_spend_usd_cents: u64,
|
||||
pub lifetime_credits_spend_usd_cents: u64,
|
||||
pub lifetime_refund_usd_cents: u64,
|
||||
pub lifetime_net_spend_usd_cents: u64,
|
||||
pub lifetime_payment_count: u32,
|
||||
pub lifetime_refund_count: u32,
|
||||
pub maybe_first_payment_at: Option<DateTime<Utc>>,
|
||||
pub first_spend_usd_cents: u64,
|
||||
pub maybe_last_payment_at: Option<DateTime<Utc>>,
|
||||
pub last_spend_usd_cents: u64,
|
||||
pub maybe_days_since_first_payment: Option<u32>,
|
||||
pub maybe_days_since_last_payment: Option<u32>,
|
||||
|
||||
pub net_spend_7d_usd_cents: i64,
|
||||
pub net_spend_prev_7d_usd_cents: i64,
|
||||
pub net_spend_14d_usd_cents: i64,
|
||||
pub net_spend_prev_14d_usd_cents: i64,
|
||||
pub net_spend_30d_usd_cents: i64,
|
||||
pub net_spend_prev_30d_usd_cents: i64,
|
||||
pub net_spend_60d_usd_cents: i64,
|
||||
pub net_spend_90d_usd_cents: i64,
|
||||
pub net_spend_this_year_usd_cents: i64,
|
||||
|
||||
pub avg_weekly_net_spend_4w_usd_cents: i64,
|
||||
pub avg_weekly_net_spend_12w_usd_cents: i64,
|
||||
pub active_weeks_in_last_4: u8,
|
||||
pub active_weeks_in_last_8: u8,
|
||||
pub active_weeks_in_last_12: u8,
|
||||
pub active_weeks_in_last_24: u8,
|
||||
pub active_weeks_in_last_52: u8,
|
||||
pub consecutive_active_weeks: u32,
|
||||
pub consecutive_inactive_weeks: u32,
|
||||
pub maybe_weeks_since_last_spend: Option<u32>,
|
||||
|
||||
pub is_active_subscriber: bool,
|
||||
pub maybe_subscription_interval: Option<String>,
|
||||
pub maybe_reengagement_score: Option<u32>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
pub mod get_summary;
|
||||
pub mod reengagement_list;
|
||||
pub mod top_users_list;
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::users::UserToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
pub const MODERATOR_REENGAGEMENT_LIST_PATH: &str =
|
||||
"/v1/moderation/user_spend_summaries/reengagement_list";
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct ModeratorReengagementListQueryParams {
|
||||
/// Row offset for pagination (page size is fixed at 200). Pass `next_offset` from the previous page.
|
||||
pub offset: Option<u64>,
|
||||
/// Payments namespace. Defaults to `artcraft`.
|
||||
pub payments_namespace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModeratorReengagementListResponse {
|
||||
pub success: bool,
|
||||
pub candidates: Vec<ReengagementCandidateEntry>,
|
||||
/// Offset for the next page, or `None` if this was the last page.
|
||||
pub maybe_next_offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ReengagementCandidateEntry {
|
||||
pub user_token: UserToken,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub email_gravatar_hash: String,
|
||||
pub reengagement_score: u32,
|
||||
pub lifetime_net_spend_usd_cents: u64,
|
||||
pub maybe_last_payment_at: Option<DateTime<Utc>>,
|
||||
pub maybe_days_since_last_payment: Option<u32>,
|
||||
pub maybe_weeks_since_last_spend: Option<u32>,
|
||||
pub is_active_subscriber: bool,
|
||||
}
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::users::UserToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
pub const MODERATOR_TOP_USERS_LIST_PATH: &str =
|
||||
"/v1/moderation/user_spend_summaries/top_users_list";
|
||||
|
||||
/// Net-spend window that selects which column `top_users_list` sorts by.
|
||||
#[derive(Deserialize, Serialize, Clone, Copy, ToSchema)]
|
||||
pub enum TopUsersWindow {
|
||||
#[serde(rename = "7d")]
|
||||
SevenDays,
|
||||
#[serde(rename = "14d")]
|
||||
FourteenDays,
|
||||
#[serde(rename = "30d")]
|
||||
ThirtyDays,
|
||||
#[serde(rename = "60d")]
|
||||
SixtyDays,
|
||||
#[serde(rename = "90d")]
|
||||
NinetyDays,
|
||||
#[serde(rename = "1y")]
|
||||
OneYear,
|
||||
#[serde(rename = "lifetime")]
|
||||
Lifetime,
|
||||
}
|
||||
|
||||
impl TopUsersWindow {
|
||||
/// The `window` key the `list_top_users_by_net_spend` query matches on.
|
||||
pub fn as_query_key(self) -> &'static str {
|
||||
match self {
|
||||
TopUsersWindow::SevenDays => "7d",
|
||||
TopUsersWindow::FourteenDays => "14d",
|
||||
TopUsersWindow::ThirtyDays => "30d",
|
||||
TopUsersWindow::SixtyDays => "60d",
|
||||
TopUsersWindow::NinetyDays => "90d",
|
||||
TopUsersWindow::OneYear => "1y",
|
||||
TopUsersWindow::Lifetime => "lifetime",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct ModeratorTopUsersListQueryParams {
|
||||
/// Sort window: `7d`, `14d`, `30d`, `60d`, `90d`, `1y`, or `lifetime` (default).
|
||||
pub window: Option<TopUsersWindow>,
|
||||
/// Row offset for pagination (page size is fixed at 200). Pass `next_offset` from the previous page.
|
||||
pub offset: Option<u64>,
|
||||
/// Payments namespace. Defaults to `artcraft`.
|
||||
pub payments_namespace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModeratorTopUsersListResponse {
|
||||
pub success: bool,
|
||||
/// The window that was sorted by (echo of the request).
|
||||
pub window: String,
|
||||
pub users: Vec<TopUserEntry>,
|
||||
/// Offset for the next page, or `None` if this was the last page.
|
||||
pub maybe_next_offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TopUserEntry {
|
||||
pub user_token: UserToken,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub email_gravatar_hash: String,
|
||||
pub lifetime_net_spend_usd_cents: u64,
|
||||
pub net_spend_7d_usd_cents: i64,
|
||||
pub net_spend_14d_usd_cents: i64,
|
||||
pub net_spend_30d_usd_cents: i64,
|
||||
pub net_spend_60d_usd_cents: i64,
|
||||
pub net_spend_90d_usd_cents: i64,
|
||||
pub net_spend_this_year_usd_cents: i64,
|
||||
pub is_active_subscriber: bool,
|
||||
pub maybe_reengagement_score: Option<u32>,
|
||||
}
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `CommonAspectRatio` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientAspectRatio {
|
||||
Auto,
|
||||
Square,
|
||||
WideThreeByTwo,
|
||||
WideFourByThree,
|
||||
WideFiveByFour,
|
||||
WideSixteenByNine,
|
||||
WideTwentyOneByNine,
|
||||
TallTwoByThree,
|
||||
TallThreeByFour,
|
||||
TallFourByFive,
|
||||
TallNineBySixteen,
|
||||
TallNineByTwentyOne,
|
||||
Wide,
|
||||
Tall,
|
||||
Auto2k,
|
||||
Auto3k,
|
||||
Auto4k,
|
||||
SquareHd,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientAspectRatio {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"auto" => Self::Auto,
|
||||
"square" => Self::Square,
|
||||
"wide_three_by_two" => Self::WideThreeByTwo,
|
||||
"wide_four_by_three" => Self::WideFourByThree,
|
||||
"wide_five_by_four" => Self::WideFiveByFour,
|
||||
"wide_sixteen_by_nine" => Self::WideSixteenByNine,
|
||||
"wide_twenty_one_by_nine" => Self::WideTwentyOneByNine,
|
||||
"tall_two_by_three" => Self::TallTwoByThree,
|
||||
"tall_three_by_four" => Self::TallThreeByFour,
|
||||
"tall_four_by_five" => Self::TallFourByFive,
|
||||
"tall_nine_by_sixteen" => Self::TallNineBySixteen,
|
||||
"tall_nine_by_twenty_one" => Self::TallNineByTwentyOne,
|
||||
"wide" => Self::Wide,
|
||||
"tall" => Self::Tall,
|
||||
"auto_2k" => Self::Auto2k,
|
||||
"auto_3k" => Self::Auto3k,
|
||||
"auto_4k" => Self::Auto4k,
|
||||
"square_hd" => Self::SquareHd,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientAspectRatio> for String {
|
||||
fn from(value: ApiClientAspectRatio) -> Self {
|
||||
match value {
|
||||
ApiClientAspectRatio::Auto => "auto".to_string(),
|
||||
ApiClientAspectRatio::Square => "square".to_string(),
|
||||
ApiClientAspectRatio::WideThreeByTwo => "wide_three_by_two".to_string(),
|
||||
ApiClientAspectRatio::WideFourByThree => "wide_four_by_three".to_string(),
|
||||
ApiClientAspectRatio::WideFiveByFour => "wide_five_by_four".to_string(),
|
||||
ApiClientAspectRatio::WideSixteenByNine => "wide_sixteen_by_nine".to_string(),
|
||||
ApiClientAspectRatio::WideTwentyOneByNine => "wide_twenty_one_by_nine".to_string(),
|
||||
ApiClientAspectRatio::TallTwoByThree => "tall_two_by_three".to_string(),
|
||||
ApiClientAspectRatio::TallThreeByFour => "tall_three_by_four".to_string(),
|
||||
ApiClientAspectRatio::TallFourByFive => "tall_four_by_five".to_string(),
|
||||
ApiClientAspectRatio::TallNineBySixteen => "tall_nine_by_sixteen".to_string(),
|
||||
ApiClientAspectRatio::TallNineByTwentyOne => "tall_nine_by_twenty_one".to_string(),
|
||||
ApiClientAspectRatio::Wide => "wide".to_string(),
|
||||
ApiClientAspectRatio::Tall => "tall".to_string(),
|
||||
ApiClientAspectRatio::Auto2k => "auto_2k".to_string(),
|
||||
ApiClientAspectRatio::Auto3k => "auto_3k".to_string(),
|
||||
ApiClientAspectRatio::Auto4k => "auto_4k".to_string(),
|
||||
ApiClientAspectRatio::SquareHd => "square_hd".to_string(),
|
||||
ApiClientAspectRatio::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientAspectRatio = serde_json::from_str("\"auto\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientAspectRatio::Auto);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"auto\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientAspectRatio = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientAspectRatio::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientAspectRatio::Auto, "auto"),
|
||||
(ApiClientAspectRatio::Square, "square"),
|
||||
(ApiClientAspectRatio::WideThreeByTwo, "wide_three_by_two"),
|
||||
(ApiClientAspectRatio::WideFourByThree, "wide_four_by_three"),
|
||||
(ApiClientAspectRatio::WideFiveByFour, "wide_five_by_four"),
|
||||
(ApiClientAspectRatio::WideSixteenByNine, "wide_sixteen_by_nine"),
|
||||
(ApiClientAspectRatio::WideTwentyOneByNine, "wide_twenty_one_by_nine"),
|
||||
(ApiClientAspectRatio::TallTwoByThree, "tall_two_by_three"),
|
||||
(ApiClientAspectRatio::TallThreeByFour, "tall_three_by_four"),
|
||||
(ApiClientAspectRatio::TallFourByFive, "tall_four_by_five"),
|
||||
(ApiClientAspectRatio::TallNineBySixteen, "tall_nine_by_sixteen"),
|
||||
(ApiClientAspectRatio::TallNineByTwentyOne, "tall_nine_by_twenty_one"),
|
||||
(ApiClientAspectRatio::Wide, "wide"),
|
||||
(ApiClientAspectRatio::Tall, "tall"),
|
||||
(ApiClientAspectRatio::Auto2k, "auto_2k"),
|
||||
(ApiClientAspectRatio::Auto3k, "auto_3k"),
|
||||
(ApiClientAspectRatio::Auto4k, "auto_4k"),
|
||||
(ApiClientAspectRatio::SquareHd, "square_hd"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientAspectRatio = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `CommonBitrate` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientBitrate {
|
||||
Normal,
|
||||
High,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientBitrate {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"normal" => Self::Normal,
|
||||
"high" => Self::High,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientBitrate> for String {
|
||||
fn from(value: ApiClientBitrate) -> Self {
|
||||
match value {
|
||||
ApiClientBitrate::Normal => "normal".to_string(),
|
||||
ApiClientBitrate::High => "high".to_string(),
|
||||
ApiClientBitrate::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientBitrate = serde_json::from_str("\"normal\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientBitrate::Normal);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"normal\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientBitrate = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientBitrate::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientBitrate::Normal, "normal"),
|
||||
(ApiClientBitrate::High, "high"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientBitrate = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `GenerationProvider` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientGenerationProvider {
|
||||
Artcraft,
|
||||
Fal,
|
||||
Grok,
|
||||
Midjourney,
|
||||
Sora,
|
||||
WorldLabs,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientGenerationProvider {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"artcraft" => Self::Artcraft,
|
||||
"fal" => Self::Fal,
|
||||
"grok" => Self::Grok,
|
||||
"midjourney" => Self::Midjourney,
|
||||
"sora" => Self::Sora,
|
||||
"world_labs" => Self::WorldLabs,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientGenerationProvider> for String {
|
||||
fn from(value: ApiClientGenerationProvider) -> Self {
|
||||
match value {
|
||||
ApiClientGenerationProvider::Artcraft => "artcraft".to_string(),
|
||||
ApiClientGenerationProvider::Fal => "fal".to_string(),
|
||||
ApiClientGenerationProvider::Grok => "grok".to_string(),
|
||||
ApiClientGenerationProvider::Midjourney => "midjourney".to_string(),
|
||||
ApiClientGenerationProvider::Sora => "sora".to_string(),
|
||||
ApiClientGenerationProvider::WorldLabs => "world_labs".to_string(),
|
||||
ApiClientGenerationProvider::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientGenerationProvider = serde_json::from_str("\"artcraft\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientGenerationProvider::Artcraft);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"artcraft\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientGenerationProvider = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientGenerationProvider::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientGenerationProvider::Artcraft, "artcraft"),
|
||||
(ApiClientGenerationProvider::Fal, "fal"),
|
||||
(ApiClientGenerationProvider::Grok, "grok"),
|
||||
(ApiClientGenerationProvider::Midjourney, "midjourney"),
|
||||
(ApiClientGenerationProvider::Sora, "sora"),
|
||||
(ApiClientGenerationProvider::WorldLabs, "world_labs"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientGenerationProvider = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `CommonImageModel` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientImageModel {
|
||||
Flux1Dev,
|
||||
Flux1Schnell,
|
||||
FluxPro11,
|
||||
FluxPro11Ultra,
|
||||
GptImage1,
|
||||
GptImage1p5,
|
||||
GptImage2,
|
||||
GrokImagineImage,
|
||||
GrokImagineImageQuality,
|
||||
Midjourney7,
|
||||
Midjourney7Niji,
|
||||
Midjourney8,
|
||||
NanoBanana,
|
||||
NanoBanana2,
|
||||
NanoBananaPro,
|
||||
Seedream4,
|
||||
Seedream4p5,
|
||||
Seedream5Lite,
|
||||
QwenEdit2511Angles,
|
||||
Flux2LoraAngles,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientImageModel {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"flux_1_dev" => Self::Flux1Dev,
|
||||
"flux_1_schnell" => Self::Flux1Schnell,
|
||||
"flux_pro_1p1" => Self::FluxPro11,
|
||||
"flux_pro_1p1_ultra" => Self::FluxPro11Ultra,
|
||||
"gpt_image_1" => Self::GptImage1,
|
||||
"gpt_image_1p5" => Self::GptImage1p5,
|
||||
"gpt_image_2" => Self::GptImage2,
|
||||
"grok_imagine_image" => Self::GrokImagineImage,
|
||||
"grok_imagine_image_q" => Self::GrokImagineImageQuality,
|
||||
"midjourney_7" => Self::Midjourney7,
|
||||
"midjourney_7_niji" => Self::Midjourney7Niji,
|
||||
"midjourney_8" => Self::Midjourney8,
|
||||
"nano_banana" => Self::NanoBanana,
|
||||
"nano_banana_2" => Self::NanoBanana2,
|
||||
"nano_banana_pro" => Self::NanoBananaPro,
|
||||
"seedream_4" => Self::Seedream4,
|
||||
"seedream_4p5" => Self::Seedream4p5,
|
||||
"seedream_5_lite" => Self::Seedream5Lite,
|
||||
"qwen_edit_2511_angles" => Self::QwenEdit2511Angles,
|
||||
"flux_2_lora_angles" => Self::Flux2LoraAngles,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientImageModel> for String {
|
||||
fn from(value: ApiClientImageModel) -> Self {
|
||||
match value {
|
||||
ApiClientImageModel::Flux1Dev => "flux_1_dev".to_string(),
|
||||
ApiClientImageModel::Flux1Schnell => "flux_1_schnell".to_string(),
|
||||
ApiClientImageModel::FluxPro11 => "flux_pro_1p1".to_string(),
|
||||
ApiClientImageModel::FluxPro11Ultra => "flux_pro_1p1_ultra".to_string(),
|
||||
ApiClientImageModel::GptImage1 => "gpt_image_1".to_string(),
|
||||
ApiClientImageModel::GptImage1p5 => "gpt_image_1p5".to_string(),
|
||||
ApiClientImageModel::GptImage2 => "gpt_image_2".to_string(),
|
||||
ApiClientImageModel::GrokImagineImage => "grok_imagine_image".to_string(),
|
||||
ApiClientImageModel::GrokImagineImageQuality => "grok_imagine_image_q".to_string(),
|
||||
ApiClientImageModel::Midjourney7 => "midjourney_7".to_string(),
|
||||
ApiClientImageModel::Midjourney7Niji => "midjourney_7_niji".to_string(),
|
||||
ApiClientImageModel::Midjourney8 => "midjourney_8".to_string(),
|
||||
ApiClientImageModel::NanoBanana => "nano_banana".to_string(),
|
||||
ApiClientImageModel::NanoBanana2 => "nano_banana_2".to_string(),
|
||||
ApiClientImageModel::NanoBananaPro => "nano_banana_pro".to_string(),
|
||||
ApiClientImageModel::Seedream4 => "seedream_4".to_string(),
|
||||
ApiClientImageModel::Seedream4p5 => "seedream_4p5".to_string(),
|
||||
ApiClientImageModel::Seedream5Lite => "seedream_5_lite".to_string(),
|
||||
ApiClientImageModel::QwenEdit2511Angles => "qwen_edit_2511_angles".to_string(),
|
||||
ApiClientImageModel::Flux2LoraAngles => "flux_2_lora_angles".to_string(),
|
||||
ApiClientImageModel::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientImageModel = serde_json::from_str("\"flux_1_dev\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientImageModel::Flux1Dev);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"flux_1_dev\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientImageModel = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientImageModel::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientImageModel::Flux1Dev, "flux_1_dev"),
|
||||
(ApiClientImageModel::Flux1Schnell, "flux_1_schnell"),
|
||||
(ApiClientImageModel::FluxPro11, "flux_pro_1p1"),
|
||||
(ApiClientImageModel::FluxPro11Ultra, "flux_pro_1p1_ultra"),
|
||||
(ApiClientImageModel::GptImage1, "gpt_image_1"),
|
||||
(ApiClientImageModel::GptImage1p5, "gpt_image_1p5"),
|
||||
(ApiClientImageModel::GptImage2, "gpt_image_2"),
|
||||
(ApiClientImageModel::GrokImagineImage, "grok_imagine_image"),
|
||||
(ApiClientImageModel::GrokImagineImageQuality, "grok_imagine_image_q"),
|
||||
(ApiClientImageModel::Midjourney7, "midjourney_7"),
|
||||
(ApiClientImageModel::Midjourney7Niji, "midjourney_7_niji"),
|
||||
(ApiClientImageModel::Midjourney8, "midjourney_8"),
|
||||
(ApiClientImageModel::NanoBanana, "nano_banana"),
|
||||
(ApiClientImageModel::NanoBanana2, "nano_banana_2"),
|
||||
(ApiClientImageModel::NanoBananaPro, "nano_banana_pro"),
|
||||
(ApiClientImageModel::Seedream4, "seedream_4"),
|
||||
(ApiClientImageModel::Seedream4p5, "seedream_4p5"),
|
||||
(ApiClientImageModel::Seedream5Lite, "seedream_5_lite"),
|
||||
(ApiClientImageModel::QwenEdit2511Angles, "qwen_edit_2511_angles"),
|
||||
(ApiClientImageModel::Flux2LoraAngles, "flux_2_lora_angles"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientImageModel = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `ModelCreator` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientModelCreator {
|
||||
Alibaba,
|
||||
ArtCraft,
|
||||
BlackForestLabs,
|
||||
Bytedance,
|
||||
Fal,
|
||||
Google,
|
||||
Grok,
|
||||
Hailuo,
|
||||
Higgsfield,
|
||||
Kling,
|
||||
Krea,
|
||||
Midjourney,
|
||||
OpenAi,
|
||||
OpenArt,
|
||||
Recraft,
|
||||
Replicate,
|
||||
Runway,
|
||||
Stability,
|
||||
Tencent,
|
||||
TensorArt,
|
||||
Vidu,
|
||||
WorldLabs,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientModelCreator {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"alibaba" => Self::Alibaba,
|
||||
"artcraft" => Self::ArtCraft,
|
||||
"black_forest_labs" => Self::BlackForestLabs,
|
||||
"bytedance" => Self::Bytedance,
|
||||
"fal" => Self::Fal,
|
||||
"google" => Self::Google,
|
||||
"grok" => Self::Grok,
|
||||
"hailuo" => Self::Hailuo,
|
||||
"higgsfield" => Self::Higgsfield,
|
||||
"kling" => Self::Kling,
|
||||
"krea" => Self::Krea,
|
||||
"midjourney" => Self::Midjourney,
|
||||
"open_ai" => Self::OpenAi,
|
||||
"open_art" => Self::OpenArt,
|
||||
"recraft" => Self::Recraft,
|
||||
"replicate" => Self::Replicate,
|
||||
"runway" => Self::Runway,
|
||||
"stability" => Self::Stability,
|
||||
"tencent" => Self::Tencent,
|
||||
"tensor_art" => Self::TensorArt,
|
||||
"vidu" => Self::Vidu,
|
||||
"world_labs" => Self::WorldLabs,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientModelCreator> for String {
|
||||
fn from(value: ApiClientModelCreator) -> Self {
|
||||
match value {
|
||||
ApiClientModelCreator::Alibaba => "alibaba".to_string(),
|
||||
ApiClientModelCreator::ArtCraft => "artcraft".to_string(),
|
||||
ApiClientModelCreator::BlackForestLabs => "black_forest_labs".to_string(),
|
||||
ApiClientModelCreator::Bytedance => "bytedance".to_string(),
|
||||
ApiClientModelCreator::Fal => "fal".to_string(),
|
||||
ApiClientModelCreator::Google => "google".to_string(),
|
||||
ApiClientModelCreator::Grok => "grok".to_string(),
|
||||
ApiClientModelCreator::Hailuo => "hailuo".to_string(),
|
||||
ApiClientModelCreator::Higgsfield => "higgsfield".to_string(),
|
||||
ApiClientModelCreator::Kling => "kling".to_string(),
|
||||
ApiClientModelCreator::Krea => "krea".to_string(),
|
||||
ApiClientModelCreator::Midjourney => "midjourney".to_string(),
|
||||
ApiClientModelCreator::OpenAi => "open_ai".to_string(),
|
||||
ApiClientModelCreator::OpenArt => "open_art".to_string(),
|
||||
ApiClientModelCreator::Recraft => "recraft".to_string(),
|
||||
ApiClientModelCreator::Replicate => "replicate".to_string(),
|
||||
ApiClientModelCreator::Runway => "runway".to_string(),
|
||||
ApiClientModelCreator::Stability => "stability".to_string(),
|
||||
ApiClientModelCreator::Tencent => "tencent".to_string(),
|
||||
ApiClientModelCreator::TensorArt => "tensor_art".to_string(),
|
||||
ApiClientModelCreator::Vidu => "vidu".to_string(),
|
||||
ApiClientModelCreator::WorldLabs => "world_labs".to_string(),
|
||||
ApiClientModelCreator::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientModelCreator = serde_json::from_str("\"alibaba\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientModelCreator::Alibaba);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"alibaba\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientModelCreator = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientModelCreator::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientModelCreator::Alibaba, "alibaba"),
|
||||
(ApiClientModelCreator::ArtCraft, "artcraft"),
|
||||
(ApiClientModelCreator::BlackForestLabs, "black_forest_labs"),
|
||||
(ApiClientModelCreator::Bytedance, "bytedance"),
|
||||
(ApiClientModelCreator::Fal, "fal"),
|
||||
(ApiClientModelCreator::Google, "google"),
|
||||
(ApiClientModelCreator::Grok, "grok"),
|
||||
(ApiClientModelCreator::Hailuo, "hailuo"),
|
||||
(ApiClientModelCreator::Higgsfield, "higgsfield"),
|
||||
(ApiClientModelCreator::Kling, "kling"),
|
||||
(ApiClientModelCreator::Krea, "krea"),
|
||||
(ApiClientModelCreator::Midjourney, "midjourney"),
|
||||
(ApiClientModelCreator::OpenAi, "open_ai"),
|
||||
(ApiClientModelCreator::OpenArt, "open_art"),
|
||||
(ApiClientModelCreator::Recraft, "recraft"),
|
||||
(ApiClientModelCreator::Replicate, "replicate"),
|
||||
(ApiClientModelCreator::Runway, "runway"),
|
||||
(ApiClientModelCreator::Stability, "stability"),
|
||||
(ApiClientModelCreator::Tencent, "tencent"),
|
||||
(ApiClientModelCreator::TensorArt, "tensor_art"),
|
||||
(ApiClientModelCreator::Vidu, "vidu"),
|
||||
(ApiClientModelCreator::WorldLabs, "world_labs"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientModelCreator = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `CommonQuality` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientQuality {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientQuality {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"high" => Self::High,
|
||||
"medium" => Self::Medium,
|
||||
"low" => Self::Low,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientQuality> for String {
|
||||
fn from(value: ApiClientQuality) -> Self {
|
||||
match value {
|
||||
ApiClientQuality::High => "high".to_string(),
|
||||
ApiClientQuality::Medium => "medium".to_string(),
|
||||
ApiClientQuality::Low => "low".to_string(),
|
||||
ApiClientQuality::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientQuality = serde_json::from_str("\"high\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientQuality::High);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"high\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientQuality = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientQuality::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientQuality::High, "high"),
|
||||
(ApiClientQuality::Medium, "medium"),
|
||||
(ApiClientQuality::Low, "low"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientQuality = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `CommonResolution` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientResolution {
|
||||
OneK,
|
||||
TwoK,
|
||||
ThreeK,
|
||||
FourK,
|
||||
HalfK,
|
||||
FourEightyP,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientResolution {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"one_k" => Self::OneK,
|
||||
"two_k" => Self::TwoK,
|
||||
"three_k" => Self::ThreeK,
|
||||
"four_k" => Self::FourK,
|
||||
"half_k" => Self::HalfK,
|
||||
"four_eighty_p" => Self::FourEightyP,
|
||||
"seven_twenty_p" => Self::SevenTwentyP,
|
||||
"ten_eighty_p" => Self::TenEightyP,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientResolution> for String {
|
||||
fn from(value: ApiClientResolution) -> Self {
|
||||
match value {
|
||||
ApiClientResolution::OneK => "one_k".to_string(),
|
||||
ApiClientResolution::TwoK => "two_k".to_string(),
|
||||
ApiClientResolution::ThreeK => "three_k".to_string(),
|
||||
ApiClientResolution::FourK => "four_k".to_string(),
|
||||
ApiClientResolution::HalfK => "half_k".to_string(),
|
||||
ApiClientResolution::FourEightyP => "four_eighty_p".to_string(),
|
||||
ApiClientResolution::SevenTwentyP => "seven_twenty_p".to_string(),
|
||||
ApiClientResolution::TenEightyP => "ten_eighty_p".to_string(),
|
||||
ApiClientResolution::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientResolution = serde_json::from_str("\"one_k\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientResolution::OneK);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"one_k\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientResolution = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientResolution::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientResolution::OneK, "one_k"),
|
||||
(ApiClientResolution::TwoK, "two_k"),
|
||||
(ApiClientResolution::ThreeK, "three_k"),
|
||||
(ApiClientResolution::FourK, "four_k"),
|
||||
(ApiClientResolution::HalfK, "half_k"),
|
||||
(ApiClientResolution::FourEightyP, "four_eighty_p"),
|
||||
(ApiClientResolution::SevenTwentyP, "seven_twenty_p"),
|
||||
(ApiClientResolution::TenEightyP, "ten_eighty_p"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientResolution = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-191
@@ -1,191 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `CommonVideoModel` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientVideoModel {
|
||||
GrokVideo,
|
||||
GrokImagineVideo,
|
||||
GrokImagineVideo1p5,
|
||||
Kling16Pro,
|
||||
Kling21Pro,
|
||||
Kling21Master,
|
||||
Kling2p5TurboPro,
|
||||
Kling2p6Pro,
|
||||
Kling3p0Standard,
|
||||
Kling3p0Pro,
|
||||
HappyHorse1p0,
|
||||
Seedance10Lite,
|
||||
Seedance1p5Pro,
|
||||
Seedance2p0,
|
||||
Seedance2p0Fast,
|
||||
Seedance2p0BytePlus,
|
||||
Seedance2p0BytePlusFast,
|
||||
Seedance2p0Ultra,
|
||||
Seedance2p0UltraFast,
|
||||
Seedance2p0BytePlusUltra,
|
||||
Seedance2p0BytePlusUltraFast,
|
||||
Seedance2p0Mini,
|
||||
Seedance2p0BytePlusMini,
|
||||
Seedance2p0BytePlusUltraMini,
|
||||
Sora2,
|
||||
Sora2Pro,
|
||||
Veo2,
|
||||
Veo3,
|
||||
Veo3Fast,
|
||||
Veo3p1,
|
||||
Veo3p1Fast,
|
||||
PreviewModel,
|
||||
PreviewModelFast,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientVideoModel {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"grok_video" => Self::GrokVideo,
|
||||
"grok_imagine_video" => Self::GrokImagineVideo,
|
||||
"grok_imagine_video_1p5" => Self::GrokImagineVideo1p5,
|
||||
"kling_1p6_pro" => Self::Kling16Pro,
|
||||
"kling_2p1_pro" => Self::Kling21Pro,
|
||||
"kling_2p1_master" => Self::Kling21Master,
|
||||
"kling_2p5_turbo_pro" => Self::Kling2p5TurboPro,
|
||||
"kling_2p6_pro" => Self::Kling2p6Pro,
|
||||
"kling_3p0_standard" => Self::Kling3p0Standard,
|
||||
"kling_3p0_pro" => Self::Kling3p0Pro,
|
||||
"happy_horse_1p0" => Self::HappyHorse1p0,
|
||||
"seedance_1p0_lite" => Self::Seedance10Lite,
|
||||
"seedance_1p5_pro" => Self::Seedance1p5Pro,
|
||||
"seedance_2p0" => Self::Seedance2p0,
|
||||
"seedance_2p0_fast" => Self::Seedance2p0Fast,
|
||||
"seedance_2p0_bp" => Self::Seedance2p0BytePlus,
|
||||
"seedance_2p0_bp_fast" => Self::Seedance2p0BytePlusFast,
|
||||
"seedance_2p0_u" => Self::Seedance2p0Ultra,
|
||||
"seedance_2p0_u_fast" => Self::Seedance2p0UltraFast,
|
||||
"seedance_2p0_bpu" => Self::Seedance2p0BytePlusUltra,
|
||||
"seedance_2p0_bpu_fast" => Self::Seedance2p0BytePlusUltraFast,
|
||||
"seedance_2p0_mini" => Self::Seedance2p0Mini,
|
||||
"seedance_2p0_bp_mini" => Self::Seedance2p0BytePlusMini,
|
||||
"seedance_2p0_bpu_mini" => Self::Seedance2p0BytePlusUltraMini,
|
||||
"sora_2" => Self::Sora2,
|
||||
"sora_2_pro" => Self::Sora2Pro,
|
||||
"veo_2" => Self::Veo2,
|
||||
"veo_3" => Self::Veo3,
|
||||
"veo_3_fast" => Self::Veo3Fast,
|
||||
"veo_3p1" => Self::Veo3p1,
|
||||
"veo_3p1_fast" => Self::Veo3p1Fast,
|
||||
"preview_model" => Self::PreviewModel,
|
||||
"preview_model_fast" => Self::PreviewModelFast,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientVideoModel> for String {
|
||||
fn from(value: ApiClientVideoModel) -> Self {
|
||||
match value {
|
||||
ApiClientVideoModel::GrokVideo => "grok_video".to_string(),
|
||||
ApiClientVideoModel::GrokImagineVideo => "grok_imagine_video".to_string(),
|
||||
ApiClientVideoModel::GrokImagineVideo1p5 => "grok_imagine_video_1p5".to_string(),
|
||||
ApiClientVideoModel::Kling16Pro => "kling_1p6_pro".to_string(),
|
||||
ApiClientVideoModel::Kling21Pro => "kling_2p1_pro".to_string(),
|
||||
ApiClientVideoModel::Kling21Master => "kling_2p1_master".to_string(),
|
||||
ApiClientVideoModel::Kling2p5TurboPro => "kling_2p5_turbo_pro".to_string(),
|
||||
ApiClientVideoModel::Kling2p6Pro => "kling_2p6_pro".to_string(),
|
||||
ApiClientVideoModel::Kling3p0Standard => "kling_3p0_standard".to_string(),
|
||||
ApiClientVideoModel::Kling3p0Pro => "kling_3p0_pro".to_string(),
|
||||
ApiClientVideoModel::HappyHorse1p0 => "happy_horse_1p0".to_string(),
|
||||
ApiClientVideoModel::Seedance10Lite => "seedance_1p0_lite".to_string(),
|
||||
ApiClientVideoModel::Seedance1p5Pro => "seedance_1p5_pro".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0 => "seedance_2p0".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0Fast => "seedance_2p0_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlus => "seedance_2p0_bp".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0Ultra => "seedance_2p0_u".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0UltraFast => "seedance_2p0_u_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusUltra => "seedance_2p0_bpu".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0Mini => "seedance_2p0_mini".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusMini => "seedance_2p0_bp_mini".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini".to_string(),
|
||||
ApiClientVideoModel::Sora2 => "sora_2".to_string(),
|
||||
ApiClientVideoModel::Sora2Pro => "sora_2_pro".to_string(),
|
||||
ApiClientVideoModel::Veo2 => "veo_2".to_string(),
|
||||
ApiClientVideoModel::Veo3 => "veo_3".to_string(),
|
||||
ApiClientVideoModel::Veo3Fast => "veo_3_fast".to_string(),
|
||||
ApiClientVideoModel::Veo3p1 => "veo_3p1".to_string(),
|
||||
ApiClientVideoModel::Veo3p1Fast => "veo_3p1_fast".to_string(),
|
||||
ApiClientVideoModel::PreviewModel => "preview_model".to_string(),
|
||||
ApiClientVideoModel::PreviewModelFast => "preview_model_fast".to_string(),
|
||||
ApiClientVideoModel::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientVideoModel = serde_json::from_str("\"grok_video\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientVideoModel::GrokVideo);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"grok_video\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientVideoModel = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientVideoModel::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientVideoModel::GrokVideo, "grok_video"),
|
||||
(ApiClientVideoModel::GrokImagineVideo, "grok_imagine_video"),
|
||||
(ApiClientVideoModel::GrokImagineVideo1p5, "grok_imagine_video_1p5"),
|
||||
(ApiClientVideoModel::Kling16Pro, "kling_1p6_pro"),
|
||||
(ApiClientVideoModel::Kling21Pro, "kling_2p1_pro"),
|
||||
(ApiClientVideoModel::Kling21Master, "kling_2p1_master"),
|
||||
(ApiClientVideoModel::Kling2p5TurboPro, "kling_2p5_turbo_pro"),
|
||||
(ApiClientVideoModel::Kling2p6Pro, "kling_2p6_pro"),
|
||||
(ApiClientVideoModel::Kling3p0Standard, "kling_3p0_standard"),
|
||||
(ApiClientVideoModel::Kling3p0Pro, "kling_3p0_pro"),
|
||||
(ApiClientVideoModel::HappyHorse1p0, "happy_horse_1p0"),
|
||||
(ApiClientVideoModel::Seedance10Lite, "seedance_1p0_lite"),
|
||||
(ApiClientVideoModel::Seedance1p5Pro, "seedance_1p5_pro"),
|
||||
(ApiClientVideoModel::Seedance2p0, "seedance_2p0"),
|
||||
(ApiClientVideoModel::Seedance2p0Fast, "seedance_2p0_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlus, "seedance_2p0_bp"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0Ultra, "seedance_2p0_u"),
|
||||
(ApiClientVideoModel::Seedance2p0UltraFast, "seedance_2p0_u_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusUltra, "seedance_2p0_bpu"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusUltraFast, "seedance_2p0_bpu_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0Mini, "seedance_2p0_mini"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini"),
|
||||
(ApiClientVideoModel::Sora2, "sora_2"),
|
||||
(ApiClientVideoModel::Sora2Pro, "sora_2_pro"),
|
||||
(ApiClientVideoModel::Veo2, "veo_2"),
|
||||
(ApiClientVideoModel::Veo3, "veo_3"),
|
||||
(ApiClientVideoModel::Veo3Fast, "veo_3_fast"),
|
||||
(ApiClientVideoModel::Veo3p1, "veo_3p1"),
|
||||
(ApiClientVideoModel::Veo3p1Fast, "veo_3p1_fast"),
|
||||
(ApiClientVideoModel::PreviewModel, "preview_model"),
|
||||
(ApiClientVideoModel::PreviewModelFast, "preview_model_fast"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientVideoModel = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
pub mod api_client_aspect_ratio;
|
||||
pub mod api_client_bitrate;
|
||||
pub mod api_client_generation_provider;
|
||||
pub mod api_client_image_model;
|
||||
pub mod api_client_model_creator;
|
||||
pub mod api_client_quality;
|
||||
pub mod api_client_resolution;
|
||||
pub mod api_client_video_model;
|
||||
@@ -1 +0,0 @@
|
||||
pub mod generation;
|
||||
@@ -1 +0,0 @@
|
||||
pub mod common_enums;
|
||||
@@ -1,2 +1 @@
|
||||
pub mod generate;
|
||||
pub mod models;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub mod omni_gen_list_image_models;
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
//! Self-contained, forward-compatible binding for `GET /v1/omni_gen/models/image`.
|
||||
//!
|
||||
//! IMPORTANT: This module deliberately does NOT reuse any types from
|
||||
//! `artcraft_api_defs`. Client builds may be deployed in the wild long after the
|
||||
//! server's models, enums, and response shapes have changed, so every request /
|
||||
//! response type is copied here and made permissive:
|
||||
//! - Unknown JSON fields are ignored (serde default behavior).
|
||||
//! - Missing collection/flag fields default rather than fail (`serde(default)`).
|
||||
//! - Every string-valued enum has an `Unknown(String)` catch-all so new server
|
||||
//! variants deserialize instead of erroring.
|
||||
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use crate::datatypes::common_enums::generation::api_client_aspect_ratio::ApiClientAspectRatio;
|
||||
use crate::datatypes::common_enums::generation::api_client_generation_provider::ApiClientGenerationProvider;
|
||||
use crate::datatypes::common_enums::generation::api_client_image_model::ApiClientImageModel;
|
||||
use crate::datatypes::common_enums::generation::api_client_model_creator::ApiClientModelCreator;
|
||||
use crate::datatypes::common_enums::generation::api_client_quality::ApiClientQuality;
|
||||
use crate::datatypes::common_enums::generation::api_client_resolution::ApiClientResolution;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::basic_json_get_request::basic_json_get_request;
|
||||
|
||||
pub const OMNI_GEN_IMAGE_MODELS_PATH: &str = "/v1/omni_gen/models/image";
|
||||
|
||||
/// Arguments for [`omni_gen_list_image_models`].
|
||||
pub struct OmniGenListImageModelsArgs<'a> {
|
||||
pub api_host: &'a ApiHost,
|
||||
pub maybe_creds: Option<&'a StorytellerCredentialSet>,
|
||||
/// Which provider's models to list. `None` lets the server default (artcraft).
|
||||
pub provider: Option<OmniGenImageModelsProvider>,
|
||||
}
|
||||
|
||||
/// List available image models.
|
||||
pub async fn omni_gen_list_image_models(
|
||||
args: OmniGenListImageModelsArgs<'_>,
|
||||
) -> Result<OmniGenImageModelsResponse, StorytellerError> {
|
||||
let path = match args.provider {
|
||||
Some(provider) => format!("{}?provider={}", OMNI_GEN_IMAGE_MODELS_PATH, provider.as_query_value()),
|
||||
None => OMNI_GEN_IMAGE_MODELS_PATH.to_string(),
|
||||
};
|
||||
|
||||
Ok(basic_json_get_request(args.api_host, &path, args.maybe_creds).await?)
|
||||
}
|
||||
|
||||
/// The provider filter for the models endpoint (a client-supplied request value).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OmniGenImageModelsProvider {
|
||||
/// Only models available through ArtCraft.
|
||||
Artcraft,
|
||||
/// All known models across all providers.
|
||||
All,
|
||||
}
|
||||
|
||||
impl OmniGenImageModelsProvider {
|
||||
pub fn as_query_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Artcraft => "artcraft",
|
||||
Self::All => "all",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OmniGenImageModelsProvider {
|
||||
fn default() -> Self {
|
||||
Self::Artcraft
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ Response types ============================
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenImageModelsResponse {
|
||||
#[serde(default)]
|
||||
pub success: bool,
|
||||
#[serde(default)]
|
||||
pub models: Vec<OmniGenImageModelDetails>,
|
||||
#[serde(default)]
|
||||
pub providers: Vec<OmniGenImageModelProviderDetails>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenImageModelProviderDetails {
|
||||
pub provider: ApiClientGenerationProvider,
|
||||
#[serde(default)]
|
||||
pub models: Vec<OmniGenImageProviderModelDetails>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenImageProviderModelDetails {
|
||||
pub model: ApiClientImageModel,
|
||||
#[serde(default)]
|
||||
pub overrides: Option<OmniGenImageModelDetails>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenImageModelDetails {
|
||||
pub model: ApiClientImageModel,
|
||||
#[serde(default)]
|
||||
pub model_creator: Option<ApiClientModelCreator>,
|
||||
#[serde(default)]
|
||||
pub full_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_prompt_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub text_prompt_max_length: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub negative_text_prompt_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub negative_text_prompt_max_length: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub image_refs_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub image_refs_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub has_fixed_editing_aspect_ratio: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub aspect_ratio_options: Option<Vec<ApiClientAspectRatio>>,
|
||||
#[serde(default)]
|
||||
pub aspect_ratio_default: Option<ApiClientAspectRatio>,
|
||||
#[serde(default)]
|
||||
pub aspect_ratio_default_when_editing: Option<ApiClientAspectRatio>,
|
||||
#[serde(default)]
|
||||
pub resolution_options: Option<Vec<ApiClientResolution>>,
|
||||
#[serde(default)]
|
||||
pub resolution_default: Option<ApiClientResolution>,
|
||||
#[serde(default)]
|
||||
pub quality_options: Option<Vec<ApiClientQuality>>,
|
||||
#[serde(default)]
|
||||
pub default_quality: Option<ApiClientQuality>,
|
||||
#[serde(default)]
|
||||
pub batch_size_min: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub batch_size_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub batch_size_options: Option<Vec<u16>>,
|
||||
#[serde(default)]
|
||||
pub batch_size_default: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub is_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn response_tolerates_unknown_fields_and_variants() {
|
||||
let json = r#"{
|
||||
"success": true,
|
||||
"models": [
|
||||
{
|
||||
"model": "brand_new_image_model",
|
||||
"model_creator": "some_new_studio",
|
||||
"aspect_ratio_options": ["square", "some_new_ratio"],
|
||||
"surprise_new_field": 123
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{ "provider": "new_provider", "models": [ { "model": "nano_banana_pro" } ] }
|
||||
],
|
||||
"another_unexpected_top_level_field": "ignored"
|
||||
}"#;
|
||||
|
||||
let resp: OmniGenImageModelsResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.success);
|
||||
assert_eq!(resp.models.len(), 1);
|
||||
assert_eq!(resp.models[0].model, ApiClientImageModel::Unknown("brand_new_image_model".to_string()));
|
||||
assert_eq!(resp.models[0].model_creator, Some(ApiClientModelCreator::Unknown("some_new_studio".to_string())));
|
||||
let ratios = resp.models[0].aspect_ratio_options.as_ref().unwrap();
|
||||
assert_eq!(ratios[0], ApiClientAspectRatio::Square);
|
||||
assert_eq!(ratios[1], ApiClientAspectRatio::Unknown("some_new_ratio".to_string()));
|
||||
assert_eq!(resp.models[0].is_disabled, None);
|
||||
assert_eq!(resp.providers[0].provider, ApiClientGenerationProvider::Unknown("new_provider".to_string()));
|
||||
assert_eq!(resp.providers[0].models[0].model, ApiClientImageModel::NanoBananaPro);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_object_uses_defaults() {
|
||||
let resp: OmniGenImageModelsResponse = serde_json::from_str("{}").unwrap();
|
||||
assert!(!resp.success);
|
||||
assert!(resp.models.is_empty());
|
||||
assert!(resp.providers.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Live tests that hit real servers. All `#[ignore]` so they never run by default.
|
||||
/// Run explicitly, e.g.:
|
||||
/// cargo test -p artcraft_client omni_gen_list_image_models::live_tests -- --ignored --nocapture
|
||||
#[cfg(test)]
|
||||
mod live_tests {
|
||||
mod localhost {
|
||||
use super::super::{omni_gen_list_image_models, OmniGenListImageModelsArgs};
|
||||
use crate::utils::api_host::ApiHost;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // live: hits http://localhost:12345
|
||||
async fn list_image_models() {
|
||||
let api_host = ApiHost::Localhost { port: 12345 };
|
||||
let response = omni_gen_list_image_models(OmniGenListImageModelsArgs {
|
||||
api_host: &api_host,
|
||||
maybe_creds: None,
|
||||
provider: None,
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
println!("[localhost] image models response: {:#?}", response);
|
||||
assert!(response.success);
|
||||
assert!(!response.models.is_empty(), "expected at least one image model");
|
||||
}
|
||||
}
|
||||
|
||||
mod production {
|
||||
use super::super::{omni_gen_list_image_models, OmniGenListImageModelsArgs};
|
||||
use crate::utils::api_host::ApiHost;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // live: hits https://api.storyteller.ai
|
||||
async fn list_image_models() {
|
||||
let api_host = ApiHost::Storyteller;
|
||||
let response = omni_gen_list_image_models(OmniGenListImageModelsArgs {
|
||||
api_host: &api_host,
|
||||
maybe_creds: None,
|
||||
provider: None,
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
println!("[production] image models response: {:#?}", response);
|
||||
assert!(response.success);
|
||||
assert!(!response.models.is_empty(), "expected at least one image model");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod image;
|
||||
pub mod video;
|
||||
@@ -1 +0,0 @@
|
||||
pub mod omni_gen_list_video_models;
|
||||
-291
@@ -1,291 +0,0 @@
|
||||
//! Self-contained, forward-compatible binding for `GET /v1/omni_gen/models/video`.
|
||||
//!
|
||||
//! IMPORTANT: This module deliberately does NOT reuse any types from
|
||||
//! `artcraft_api_defs`. Client builds may be deployed in the wild long after the
|
||||
//! server's models, enums, and response shapes have changed, so every request /
|
||||
//! response type is copied here and made permissive:
|
||||
//! - Unknown JSON fields are ignored (serde default behavior).
|
||||
//! - Missing collection/flag fields default rather than fail (`serde(default)`).
|
||||
//! - Every string-valued enum has an `Unknown(String)` catch-all so new server
|
||||
//! variants deserialize instead of erroring.
|
||||
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::basic_json_get_request::basic_json_get_request;
|
||||
use crate::datatypes::common_enums::generation::api_client_aspect_ratio::ApiClientAspectRatio;
|
||||
use crate::datatypes::common_enums::generation::api_client_bitrate::ApiClientBitrate;
|
||||
use crate::datatypes::common_enums::generation::api_client_generation_provider::ApiClientGenerationProvider;
|
||||
use crate::datatypes::common_enums::generation::api_client_model_creator::ApiClientModelCreator;
|
||||
use crate::datatypes::common_enums::generation::api_client_quality::ApiClientQuality;
|
||||
use crate::datatypes::common_enums::generation::api_client_resolution::ApiClientResolution;
|
||||
use crate::datatypes::common_enums::generation::api_client_video_model::ApiClientVideoModel;
|
||||
|
||||
pub const OMNI_GEN_VIDEO_MODELS_PATH: &str = "/v1/omni_gen/models/video";
|
||||
|
||||
/// Arguments for [`omni_gen_list_video_models`].
|
||||
pub struct OmniGenListVideoModelsArgs<'a> {
|
||||
pub api_host: &'a ApiHost,
|
||||
pub maybe_creds: Option<&'a StorytellerCredentialSet>,
|
||||
/// Which provider's models to list. `None` lets the server default (artcraft).
|
||||
pub provider: Option<OmniGenVideoModelsProvider>,
|
||||
}
|
||||
|
||||
/// List available video models.
|
||||
pub async fn omni_gen_list_video_models(
|
||||
args: OmniGenListVideoModelsArgs<'_>,
|
||||
) -> Result<OmniGenVideoModelsResponse, StorytellerError> {
|
||||
let path = match args.provider {
|
||||
Some(provider) => format!("{}?provider={}", OMNI_GEN_VIDEO_MODELS_PATH, provider.as_query_value()),
|
||||
None => OMNI_GEN_VIDEO_MODELS_PATH.to_string(),
|
||||
};
|
||||
|
||||
Ok(basic_json_get_request(args.api_host, &path, args.maybe_creds).await?)
|
||||
}
|
||||
|
||||
/// The provider filter for the models endpoint (a client-supplied request value).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OmniGenVideoModelsProvider {
|
||||
/// Only models available through ArtCraft.
|
||||
Artcraft,
|
||||
/// All known models across all providers.
|
||||
All,
|
||||
}
|
||||
|
||||
impl OmniGenVideoModelsProvider {
|
||||
pub fn as_query_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Artcraft => "artcraft",
|
||||
Self::All => "all",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OmniGenVideoModelsProvider {
|
||||
fn default() -> Self {
|
||||
Self::Artcraft
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ Response types ============================
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenVideoModelsResponse {
|
||||
#[serde(default)]
|
||||
pub success: bool,
|
||||
#[serde(default)]
|
||||
pub models: Vec<OmniGenVideoModelDetails>,
|
||||
#[serde(default)]
|
||||
pub providers: Vec<OmniGenVideoModelProviderDetails>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenVideoModelProviderDetails {
|
||||
pub provider: ApiClientGenerationProvider,
|
||||
#[serde(default)]
|
||||
pub models: Vec<OmniGenVideoProviderModelDetails>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenVideoProviderModelDetails {
|
||||
pub model: ApiClientVideoModel,
|
||||
#[serde(default)]
|
||||
pub overrides: Option<OmniGenVideoModelDetails>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenVideoModelDetails {
|
||||
pub model: ApiClientVideoModel,
|
||||
#[serde(default)]
|
||||
pub model_creator: Option<ApiClientModelCreator>,
|
||||
#[serde(default)]
|
||||
pub full_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub extra_info: Option<String>,
|
||||
#[serde(default)]
|
||||
pub extra_info_short: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_to_video_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub text_prompt_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub text_prompt_max_length: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub negative_text_prompt_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub negative_text_prompt_max_length: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub starting_keyframe_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub starting_keyframe_required: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub ending_keyframe_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub image_references_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub image_references_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub video_references_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub video_references_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub video_references_max_total_duration_seconds: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub audio_references_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub audio_references_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub audio_references_max_total_duration_seconds: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub character_references_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub character_references_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub show_generate_with_sound_toggle: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub aspect_ratio_options: Option<Vec<ApiClientAspectRatio>>,
|
||||
#[serde(default)]
|
||||
pub aspect_ratio_default: Option<ApiClientAspectRatio>,
|
||||
#[serde(default)]
|
||||
pub resolution_options: Option<Vec<ApiClientResolution>>,
|
||||
#[serde(default)]
|
||||
pub resolution_default: Option<ApiClientResolution>,
|
||||
#[serde(default)]
|
||||
pub bitrate_options: Option<Vec<ApiClientBitrate>>,
|
||||
#[serde(default)]
|
||||
pub bitrate_default: Option<ApiClientBitrate>,
|
||||
#[serde(default)]
|
||||
pub quality_options: Option<Vec<ApiClientQuality>>,
|
||||
#[serde(default)]
|
||||
pub default_quality: Option<ApiClientQuality>,
|
||||
#[serde(default)]
|
||||
pub duration_seconds_min: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub duration_seconds_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub duration_seconds_max_with_image_references: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub duration_seconds_options: Option<Vec<u16>>,
|
||||
#[serde(default)]
|
||||
pub duration_seconds_default: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub batch_size_min: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub batch_size_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub batch_size_options: Option<Vec<u16>>,
|
||||
#[serde(default)]
|
||||
pub batch_size_default: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub is_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unknown_enum_value_is_preserved_and_round_trips() {
|
||||
let known: ApiClientVideoModel = serde_json::from_str("\"seedance_2p0\"").unwrap();
|
||||
assert_eq!(known, ApiClientVideoModel::Seedance2p0);
|
||||
assert_eq!(serde_json::to_string(&known).unwrap(), "\"seedance_2p0\"");
|
||||
|
||||
// A value this build has never heard of does NOT fail — it is captured verbatim.
|
||||
let unknown: ApiClientVideoModel = serde_json::from_str("\"future_model_9000\"").unwrap();
|
||||
assert_eq!(unknown, ApiClientVideoModel::Unknown("future_model_9000".to_string()));
|
||||
// ...and serializes back to exactly what came in.
|
||||
assert_eq!(serde_json::to_string(&unknown).unwrap(), "\"future_model_9000\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_tolerates_unknown_fields_and_variants() {
|
||||
let json = r#"{
|
||||
"success": true,
|
||||
"models": [
|
||||
{
|
||||
"model": "brand_new_model",
|
||||
"model_creator": "some_new_studio",
|
||||
"aspect_ratio_options": ["wide_sixteen_by_nine", "some_new_ratio"],
|
||||
"surprise_new_field": 123
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{ "provider": "new_provider", "models": [ { "model": "seedance_2p0" } ] }
|
||||
],
|
||||
"another_unexpected_top_level_field": "ignored"
|
||||
}"#;
|
||||
|
||||
let resp: OmniGenVideoModelsResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.success);
|
||||
assert_eq!(resp.models.len(), 1);
|
||||
assert_eq!(resp.models[0].model, ApiClientVideoModel::Unknown("brand_new_model".to_string()));
|
||||
assert_eq!(resp.models[0].model_creator, Some(ApiClientModelCreator::Unknown("some_new_studio".to_string())));
|
||||
let ratios = resp.models[0].aspect_ratio_options.as_ref().unwrap();
|
||||
assert_eq!(ratios[0], ApiClientAspectRatio::WideSixteenByNine);
|
||||
assert_eq!(ratios[1], ApiClientAspectRatio::Unknown("some_new_ratio".to_string()));
|
||||
assert_eq!(resp.models[0].is_disabled, None); // missing optional -> None
|
||||
assert_eq!(resp.providers[0].provider, ApiClientGenerationProvider::Unknown("new_provider".to_string()));
|
||||
assert_eq!(resp.providers[0].models[0].model, ApiClientVideoModel::Seedance2p0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_object_uses_defaults() {
|
||||
let resp: OmniGenVideoModelsResponse = serde_json::from_str("{}").unwrap();
|
||||
assert!(!resp.success);
|
||||
assert!(resp.models.is_empty());
|
||||
assert!(resp.providers.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Live tests that hit real servers. All `#[ignore]` so they never run by default.
|
||||
/// Run explicitly, e.g.:
|
||||
/// cargo test -p artcraft_client omni_gen_list_video_models::live_tests -- --ignored --nocapture
|
||||
#[cfg(test)]
|
||||
mod live_tests {
|
||||
mod localhost {
|
||||
use super::super::{omni_gen_list_video_models, OmniGenListVideoModelsArgs};
|
||||
use crate::utils::api_host::ApiHost;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // live: hits http://localhost:12345
|
||||
async fn list_video_models() {
|
||||
let api_host = ApiHost::Localhost { port: 12345 };
|
||||
let response = omni_gen_list_video_models(OmniGenListVideoModelsArgs {
|
||||
api_host: &api_host,
|
||||
maybe_creds: None,
|
||||
provider: None,
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
println!("[localhost] video models response: {:#?}", response);
|
||||
assert!(response.success);
|
||||
assert!(!response.models.is_empty(), "expected at least one video model");
|
||||
}
|
||||
}
|
||||
|
||||
mod production {
|
||||
use super::super::{omni_gen_list_video_models, OmniGenListVideoModelsArgs};
|
||||
use crate::utils::api_host::ApiHost;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // live: hits https://api.storyteller.ai
|
||||
async fn list_video_models() {
|
||||
let api_host = ApiHost::Storyteller;
|
||||
let response = omni_gen_list_video_models(OmniGenListVideoModelsArgs {
|
||||
api_host: &api_host,
|
||||
maybe_creds: None,
|
||||
provider: None,
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
println!("[production] video models response: {:#?}", response);
|
||||
assert!(response.success);
|
||||
assert!(!response.models.is_empty(), "expected at least one video model");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
//! An API client for Artcraft / Storyteller / FakeYou
|
||||
|
||||
pub mod credentials;
|
||||
pub mod datatypes;
|
||||
pub mod endpoints;
|
||||
pub mod error;
|
||||
pub mod recipes;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod tauri_image_model_to_common_model_type;
|
||||
pub mod tauri_image_model_to_enums_model;
|
||||
pub mod tauri_image_model_to_generation_model;
|
||||
pub mod tauri_image_model_to_router_model;
|
||||
pub mod tauri_image_model_to_router_model;
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
|
||||
use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel;
|
||||
|
||||
/// Map TauriImageModel to the legacy CommonModelType (enums crate).
|
||||
pub fn tauri_image_model_to_common_model_type(model: TauriImageModel) -> CommonModelType {
|
||||
match model {
|
||||
TauriImageModel::Flux1Dev => CommonModelType::Flux1Dev,
|
||||
TauriImageModel::Flux1Schnell => CommonModelType::Flux1Schnell,
|
||||
TauriImageModel::FluxPro11 => CommonModelType::FluxPro11,
|
||||
TauriImageModel::FluxPro11Ultra => CommonModelType::FluxPro11Ultra,
|
||||
TauriImageModel::GrokImage => CommonModelType::GrokImage,
|
||||
TauriImageModel::Recraft3 => CommonModelType::Recraft3,
|
||||
TauriImageModel::GptImage1 => CommonModelType::GptImage1,
|
||||
TauriImageModel::GptImage1p5 => CommonModelType::GptImage1p5,
|
||||
TauriImageModel::GptImage2 => CommonModelType::GptImage2,
|
||||
TauriImageModel::Gemini25Flash => CommonModelType::NanoBanana,
|
||||
TauriImageModel::NanoBanana => CommonModelType::NanoBanana,
|
||||
TauriImageModel::NanoBanana2 => CommonModelType::NanoBanana2,
|
||||
TauriImageModel::NanoBananaPro => CommonModelType::NanoBananaPro,
|
||||
TauriImageModel::Seedream4 => CommonModelType::Seedream4,
|
||||
TauriImageModel::Seedream4p5 => CommonModelType::Seedream4p5,
|
||||
TauriImageModel::Seedream5Lite => CommonModelType::Seedream5Lite,
|
||||
TauriImageModel::Midjourney => CommonModelType::Midjourney,
|
||||
TauriImageModel::Midjourney7 => CommonModelType::Midjourney7,
|
||||
TauriImageModel::Midjourney7Niji => CommonModelType::Midjourney7Niji,
|
||||
TauriImageModel::Midjourney8 => CommonModelType::Midjourney8,
|
||||
TauriImageModel::FluxProKontextMax => CommonModelType::FluxProKontextMax,
|
||||
TauriImageModel::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles,
|
||||
TauriImageModel::Flux2LoraAngles => CommonModelType::Flux2LoraAngles,
|
||||
TauriImageModel::FluxDevJuggernaut => CommonModelType::FluxDevJuggernaut,
|
||||
TauriImageModel::FluxPro1 => CommonModelType::FluxPro1,
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -23,13 +23,10 @@ pub fn tauri_image_model_to_enums_model(model: TauriImageModel) -> Option<Common
|
||||
TauriImageModel::Seedream5Lite => Some(CommonImageModel::Seedream5Lite),
|
||||
TauriImageModel::QwenEdit2511Angles => Some(CommonImageModel::QwenEdit2511Angles),
|
||||
TauriImageModel::Flux2LoraAngles => Some(CommonImageModel::Flux2LoraAngles),
|
||||
TauriImageModel::Midjourney7 => Some(CommonImageModel::Midjourney7),
|
||||
TauriImageModel::Midjourney7Niji => Some(CommonImageModel::Midjourney7Niji),
|
||||
TauriImageModel::Midjourney8 => Some(CommonImageModel::Midjourney8),
|
||||
// Not accounted for yet
|
||||
TauriImageModel::GrokImage => None,
|
||||
TauriImageModel::Recraft3 => None,
|
||||
TauriImageModel::Midjourney => None, // NB: Generic Midjourney is served by the native Midjourney provider.
|
||||
TauriImageModel::Midjourney => None,
|
||||
TauriImageModel::FluxProKontextMax => None,
|
||||
TauriImageModel::FluxDevJuggernaut => None,
|
||||
}
|
||||
|
||||
+1
-4
@@ -20,10 +20,7 @@ pub fn tauri_image_model_to_generation_model(model: TauriImageModel) -> Generati
|
||||
TauriImageModel::Seedream4 => GenerationModel::Seedream4,
|
||||
TauriImageModel::Seedream4p5 => GenerationModel::Seedream4p5,
|
||||
TauriImageModel::Seedream5Lite => GenerationModel::Seedream5Lite,
|
||||
TauriImageModel::Midjourney => GenerationModel::Midjourney,
|
||||
TauriImageModel::Midjourney7 => GenerationModel::Midjourney7,
|
||||
TauriImageModel::Midjourney7Niji => GenerationModel::Midjourney7Niji,
|
||||
TauriImageModel::Midjourney8 => GenerationModel::Midjourney8,
|
||||
TauriImageModel::Midjourney => GenerationModel::Flux1Dev, // Fallback
|
||||
TauriImageModel::FluxProKontextMax => GenerationModel::FluxProKontextMax,
|
||||
TauriImageModel::QwenEdit2511Angles => GenerationModel::QwenEdit2511Angles,
|
||||
TauriImageModel::Flux2LoraAngles => GenerationModel::Flux2LoraAngles,
|
||||
|
||||
+1
-4
@@ -23,13 +23,10 @@ pub fn tauri_image_model_to_router_model(model: TauriImageModel) -> Option<Route
|
||||
TauriImageModel::Seedream5Lite => Some(RouterImageModel::Seedream5Lite), // Text-to-Image
|
||||
TauriImageModel::QwenEdit2511Angles => Some(RouterImageModel::QwenEdit2511Angles),
|
||||
TauriImageModel::Flux2LoraAngles => Some(RouterImageModel::Flux2LoraAngles),
|
||||
TauriImageModel::Midjourney7 => Some(RouterImageModel::Midjourney7),
|
||||
TauriImageModel::Midjourney7Niji => Some(RouterImageModel::Midjourney7Niji),
|
||||
TauriImageModel::Midjourney8 => Some(RouterImageModel::Midjourney8),
|
||||
// Not accounted for yet
|
||||
TauriImageModel::GrokImage => None,
|
||||
TauriImageModel::Recraft3 => None,
|
||||
TauriImageModel::Midjourney => None, // NB: Generic Midjourney is served by the native Midjourney provider.
|
||||
TauriImageModel::Midjourney => None,
|
||||
TauriImageModel::FluxProKontextMax => None,
|
||||
TauriImageModel::FluxDevJuggernaut => None,
|
||||
}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
pub mod image;
|
||||
pub mod video;
|
||||
pub mod image;
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod tauri_video_model_to_generation_model;
|
||||
pub mod tauri_video_model_to_router_model;
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
use crate::core::commands::generate::generate_video::request::TauriVideoModel;
|
||||
use crate::core::events::generation_events::common::GenerationModel;
|
||||
|
||||
/// Map TauriVideoModel to the GenerationModel used in events and the task database.
|
||||
/// NB: GenerationModel serializations are stored in the tasks database — they keep
|
||||
/// their legacy spellings for compatibility.
|
||||
pub fn tauri_video_model_to_generation_model(model: TauriVideoModel) -> GenerationModel {
|
||||
match model {
|
||||
TauriVideoModel::GrokVideo => GenerationModel::GrokVideo,
|
||||
TauriVideoModel::GrokImagineVideo1p5 => GenerationModel::GrokImagineVideo1p5,
|
||||
TauriVideoModel::HappyHorse1p0 => GenerationModel::HappyHorse1p0,
|
||||
TauriVideoModel::Kling16Pro => GenerationModel::Kling1_6,
|
||||
TauriVideoModel::Kling21Master => GenerationModel::Kling21Master,
|
||||
TauriVideoModel::Kling21Pro => GenerationModel::Kling21Pro,
|
||||
TauriVideoModel::Kling2p5TurboPro => GenerationModel::Kling2p5TurboPro,
|
||||
TauriVideoModel::Kling2p6Pro => GenerationModel::Kling2p6Pro,
|
||||
TauriVideoModel::Kling3p0Pro => GenerationModel::Kling3p0Pro,
|
||||
TauriVideoModel::Kling3p0Standard => GenerationModel::Kling3p0Standard,
|
||||
TauriVideoModel::Seedance10Lite => GenerationModel::Seedance10Lite,
|
||||
TauriVideoModel::Seedance1p5Pro => GenerationModel::Seedance1p5Pro,
|
||||
TauriVideoModel::Seedance2p0 => GenerationModel::Seedance2p0,
|
||||
TauriVideoModel::Seedance2p0Fast => GenerationModel::Seedance2p0Fast,
|
||||
TauriVideoModel::Sora2 => GenerationModel::Sora2,
|
||||
TauriVideoModel::Sora2Pro => GenerationModel::Sora2Pro,
|
||||
TauriVideoModel::Veo2 => GenerationModel::Veo2,
|
||||
TauriVideoModel::Veo3 => GenerationModel::Veo3,
|
||||
TauriVideoModel::Veo3Fast => GenerationModel::Veo3Fast,
|
||||
TauriVideoModel::Veo3p1 => GenerationModel::Veo3p1,
|
||||
TauriVideoModel::Veo3p1Fast => GenerationModel::Veo3p1Fast,
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
|
||||
use crate::core::commands::generate::generate_video::request::TauriVideoModel;
|
||||
|
||||
/// Map TauriVideoModel to the artcraft_router's RouterVideoModel.
|
||||
pub fn tauri_video_model_to_router_model(model: TauriVideoModel) -> RouterVideoModel {
|
||||
match model {
|
||||
TauriVideoModel::GrokVideo => RouterVideoModel::GrokImagineVideo,
|
||||
TauriVideoModel::GrokImagineVideo1p5 => RouterVideoModel::GrokImagineVideo1p5,
|
||||
TauriVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
|
||||
TauriVideoModel::Kling16Pro => RouterVideoModel::Kling16Pro,
|
||||
TauriVideoModel::Kling21Master => RouterVideoModel::Kling21Master,
|
||||
TauriVideoModel::Kling21Pro => RouterVideoModel::Kling21Pro,
|
||||
TauriVideoModel::Kling2p5TurboPro => RouterVideoModel::Kling2p5TurboPro,
|
||||
TauriVideoModel::Kling2p6Pro => RouterVideoModel::Kling2p6Pro,
|
||||
TauriVideoModel::Kling3p0Pro => RouterVideoModel::Kling3p0Pro,
|
||||
TauriVideoModel::Kling3p0Standard => RouterVideoModel::Kling3p0Standard,
|
||||
TauriVideoModel::Seedance10Lite => RouterVideoModel::Seedance10Lite,
|
||||
TauriVideoModel::Seedance1p5Pro => RouterVideoModel::Seedance1p5Pro,
|
||||
TauriVideoModel::Seedance2p0 => RouterVideoModel::Seedance2p0,
|
||||
TauriVideoModel::Seedance2p0Fast => RouterVideoModel::Seedance2p0Fast,
|
||||
TauriVideoModel::Sora2 => RouterVideoModel::Sora2,
|
||||
TauriVideoModel::Sora2Pro => RouterVideoModel::Sora2Pro,
|
||||
TauriVideoModel::Veo2 => RouterVideoModel::Veo2,
|
||||
TauriVideoModel::Veo3 => RouterVideoModel::Veo3,
|
||||
TauriVideoModel::Veo3Fast => RouterVideoModel::Veo3Fast,
|
||||
TauriVideoModel::Veo3p1 => RouterVideoModel::Veo3p1,
|
||||
TauriVideoModel::Veo3p1Fast => RouterVideoModel::Veo3p1Fast,
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,6 @@ impl TaskEnqueueSuccess{
|
||||
Some(GenerationModel::GrokImage) => Some(TaskModelType::GrokImage),
|
||||
Some(GenerationModel::Recraft3) => Some(TaskModelType::Recraft3),
|
||||
Some(GenerationModel::GrokVideo) => Some(TaskModelType::GrokVideo),
|
||||
Some(GenerationModel::GrokImagineVideo1p5) => Some(TaskModelType::GrokImagineVideo1p5),
|
||||
Some(GenerationModel::Kling21Pro) => Some(TaskModelType::Kling21Pro),
|
||||
Some(GenerationModel::Kling21Master) => Some(TaskModelType::Kling21Master),
|
||||
Some(GenerationModel::Kling2p5TurboPro) => Some(TaskModelType::Kling2p5TurboPro),
|
||||
@@ -111,9 +110,6 @@ impl TaskEnqueueSuccess{
|
||||
Some(GenerationModel::WorldlabsMarble0p1Mini) => Some(TaskModelType::WorldlabsMarble0p1Mini),
|
||||
Some(GenerationModel::WorldlabsMarble0p1Plus) => Some(TaskModelType::WorldlabsMarble0p1Plus),
|
||||
Some(GenerationModel::Midjourney) => Some(TaskModelType::Midjourney), // NB: This is a generic Midjourney model, version unknown.
|
||||
Some(GenerationModel::Midjourney7) => Some(TaskModelType::Midjourney7),
|
||||
Some(GenerationModel::Midjourney7Niji) => Some(TaskModelType::Midjourney7Niji),
|
||||
Some(GenerationModel::Midjourney8) => Some(TaskModelType::Midjourney8),
|
||||
|
||||
// TODO: These seem wrong -
|
||||
Some(GenerationModel::Kling1_6) => Some(TaskModelType::Kling16Pro), // NB: `VideoModel::Kling16Pro`.
|
||||
|
||||
+33
-18
@@ -1,14 +1,10 @@
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
/// Unified image model enum covering text-to-image, image edit, and inpainting.
|
||||
///
|
||||
/// This is used in the Tauri command bridge.
|
||||
/// Don't change the serializations without coordinating with the frontend.
|
||||
///
|
||||
/// MIGRATION (2026-07): we're moving the frontend to send storyteller-web omni
|
||||
/// identifiers (`CommonImageModel` serde strings). Variants whose legacy Tauri
|
||||
/// id differs carry the omni id as a `#[serde(alias)]` so BOTH deserialize.
|
||||
/// Once the frontend is 100% omni ids, the legacy renames can be dropped.
|
||||
#[derive(Deserialize, Debug, Copy, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TauriImageModel {
|
||||
@@ -18,11 +14,11 @@ pub enum TauriImageModel {
|
||||
Flux1Dev,
|
||||
#[serde(rename = "flux_1_schnell")]
|
||||
Flux1Schnell,
|
||||
#[serde(rename = "flux_pro_11", alias = "flux_pro_1p1")]
|
||||
#[serde(rename = "flux_pro_11")]
|
||||
FluxPro11,
|
||||
#[serde(rename = "flux_pro_11_ultra", alias = "flux_pro_1p1_ultra")]
|
||||
#[serde(rename = "flux_pro_11_ultra")]
|
||||
FluxPro11Ultra,
|
||||
#[serde(rename = "grok_image", alias = "grok_imagine_image")]
|
||||
#[serde(rename = "grok_image")]
|
||||
GrokImage,
|
||||
#[serde(rename = "recraft_3")]
|
||||
Recraft3,
|
||||
@@ -46,19 +42,9 @@ pub enum TauriImageModel {
|
||||
Seedream4p5,
|
||||
#[serde(rename = "seedream_5_lite")]
|
||||
Seedream5Lite,
|
||||
// Generic Midjourney experience, served via the native Midjourney provider.
|
||||
#[serde(rename = "midjourney")]
|
||||
Midjourney,
|
||||
|
||||
// Versioned Midjourney models, served by storyteller-web via the
|
||||
// Artcraft provider (dispatched through artcraft_router).
|
||||
#[serde(rename = "midjourney_7")]
|
||||
Midjourney7,
|
||||
#[serde(rename = "midjourney_7_niji")]
|
||||
Midjourney7Niji,
|
||||
#[serde(rename = "midjourney_8")]
|
||||
Midjourney8,
|
||||
|
||||
// Image edit models
|
||||
|
||||
#[serde(rename = "flux_pro_kontext_max")]
|
||||
@@ -75,3 +61,32 @@ pub enum TauriImageModel {
|
||||
#[serde(rename = "flux_pro_1")]
|
||||
FluxPro1,
|
||||
}
|
||||
|
||||
impl TauriImageModel {
|
||||
pub fn to_common_model_type(&self) -> CommonModelType {
|
||||
match self {
|
||||
Self::Flux1Dev => CommonModelType::Flux1Dev,
|
||||
Self::Flux1Schnell => CommonModelType::Flux1Schnell,
|
||||
Self::FluxPro11 => CommonModelType::FluxPro11,
|
||||
Self::FluxPro11Ultra => CommonModelType::FluxPro11Ultra,
|
||||
Self::GrokImage => CommonModelType::GrokImage,
|
||||
Self::Recraft3 => CommonModelType::Recraft3,
|
||||
Self::GptImage1 => CommonModelType::GptImage1,
|
||||
Self::GptImage1p5 => CommonModelType::GptImage1p5,
|
||||
Self::GptImage2 => CommonModelType::GptImage2,
|
||||
Self::Gemini25Flash => CommonModelType::NanoBanana,
|
||||
Self::NanoBanana => CommonModelType::NanoBanana,
|
||||
Self::NanoBanana2 => CommonModelType::NanoBanana2,
|
||||
Self::NanoBananaPro => CommonModelType::NanoBananaPro,
|
||||
Self::Seedream4 => CommonModelType::Seedream4,
|
||||
Self::Seedream4p5 => CommonModelType::Seedream4p5,
|
||||
Self::Seedream5Lite => CommonModelType::Seedream5Lite,
|
||||
Self::Midjourney => CommonModelType::Midjourney,
|
||||
Self::FluxProKontextMax => CommonModelType::FluxProKontextMax,
|
||||
Self::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles,
|
||||
Self::Flux2LoraAngles => CommonModelType::Flux2LoraAngles,
|
||||
Self::FluxDevJuggernaut => CommonModelType::FluxDevJuggernaut,
|
||||
Self::FluxPro1 => CommonModelType::FluxPro1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-8
@@ -1,12 +1,13 @@
|
||||
use crate::core::api_adapters::models::video::tauri_video_model_to_generation_model::tauri_video_model_to_generation_model;
|
||||
use crate::core::api_adapters::models::video::tauri_video_model_to_router_model::tauri_video_model_to_router_model;
|
||||
use crate::core::commands::enqueue::generate_error::GenerateError;
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::core::commands::generate::generate_video::artcraft::get_storyteller_creds_or_error::get_storyteller_creds_or_error;
|
||||
use crate::core::commands::generate::generate_video::artcraft::handle_artcraft_video_via_router::handle_artcraft_video_via_router;
|
||||
use crate::core::commands::generate::generate_video::request::TauriGenerateVideoRequest;
|
||||
use crate::core::commands::generate::generate_video::request::{TauriGenerateVideoRequest, TauriVideoModel};
|
||||
use crate::core::events::generation_events::common::GenerationModel;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use anyhow::anyhow;
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub async fn handle_video_artcraft(
|
||||
@@ -18,13 +19,30 @@ pub async fn handle_video_artcraft(
|
||||
|
||||
let creds = get_storyteller_creds_or_error(app, storyteller_creds_manager)?;
|
||||
|
||||
let model = match request.model {
|
||||
let (router_model, generation_model) = match request.model {
|
||||
None => return Err(GenerateError::no_model_specified()),
|
||||
Some(model) => model,
|
||||
Some(TauriVideoModel::HappyHorse1p0) => (RouterVideoModel::HappyHorse1p0, GenerationModel::HappyHorse1p0),
|
||||
Some(TauriVideoModel::Kling16Pro) => (RouterVideoModel::Kling16Pro, GenerationModel::Kling1_6),
|
||||
Some(TauriVideoModel::Kling21Master) => (RouterVideoModel::Kling21Master, GenerationModel::Kling21Master),
|
||||
Some(TauriVideoModel::Kling21Pro) => (RouterVideoModel::Kling21Pro, GenerationModel::Kling21Pro),
|
||||
Some(TauriVideoModel::Kling2p5TurboPro) => (RouterVideoModel::Kling2p5TurboPro, GenerationModel::Kling2p5TurboPro),
|
||||
Some(TauriVideoModel::Kling2p6Pro) => (RouterVideoModel::Kling2p6Pro, GenerationModel::Kling2p6Pro),
|
||||
Some(TauriVideoModel::Kling3p0Pro) => (RouterVideoModel::Kling3p0Pro, GenerationModel::Kling3p0Pro),
|
||||
Some(TauriVideoModel::Kling3p0Standard) => (RouterVideoModel::Kling3p0Standard, GenerationModel::Kling3p0Standard),
|
||||
Some(TauriVideoModel::Seedance10Lite) => (RouterVideoModel::Seedance10Lite, GenerationModel::Seedance10Lite),
|
||||
Some(TauriVideoModel::Seedance1p5Pro) => (RouterVideoModel::Seedance1p5Pro, GenerationModel::Seedance1p5Pro),
|
||||
Some(TauriVideoModel::Seedance2p0) => (RouterVideoModel::Seedance2p0, GenerationModel::Seedance2p0),
|
||||
Some(TauriVideoModel::Seedance2p0Fast) => (RouterVideoModel::Seedance2p0Fast, GenerationModel::Seedance2p0Fast),
|
||||
Some(TauriVideoModel::Sora2) => (RouterVideoModel::Sora2, GenerationModel::Sora2),
|
||||
Some(TauriVideoModel::Sora2Pro) => (RouterVideoModel::Sora2Pro, GenerationModel::Sora2Pro),
|
||||
Some(TauriVideoModel::Veo2) => (RouterVideoModel::Veo2, GenerationModel::Veo2),
|
||||
Some(TauriVideoModel::Veo3) => (RouterVideoModel::Veo3, GenerationModel::Veo3),
|
||||
Some(TauriVideoModel::Veo3Fast) => (RouterVideoModel::Veo3Fast, GenerationModel::Veo3Fast),
|
||||
Some(TauriVideoModel::Veo3p1) => (RouterVideoModel::Veo3p1, GenerationModel::Veo3p1),
|
||||
Some(TauriVideoModel::Veo3p1Fast) => (RouterVideoModel::Veo3p1Fast, GenerationModel::Veo3p1Fast),
|
||||
Some(other) => return Err(GenerateError::AnyhowError(
|
||||
anyhow!("wrong logic: another branch should handle this: {:?}", other))),
|
||||
};
|
||||
|
||||
let router_model = tauri_video_model_to_router_model(model);
|
||||
let generation_model = tauri_video_model_to_generation_model(model);
|
||||
|
||||
handle_artcraft_video_via_router(request, app_env_configs, &creds, router_model, generation_model).await
|
||||
}
|
||||
|
||||
-4
@@ -150,10 +150,6 @@ async fn handle_request(
|
||||
|
||||
let provider = match (model, request.provider) {
|
||||
(TauriVideoModel::GrokVideo, _) => GenerationProvider::Grok,
|
||||
// NB: Grok Imagine 1.5 is served via Artcraft (storyteller-web / artcraft_router)
|
||||
// for now — force it regardless of the requested provider. In the future we
|
||||
// may serve it natively via the Grok provider.
|
||||
(TauriVideoModel::GrokImagineVideo1p5, _) => GenerationProvider::Artcraft,
|
||||
_ => request.provider.unwrap_or(GenerationProvider::Artcraft),
|
||||
};
|
||||
|
||||
|
||||
+2
-9
@@ -1,7 +1,7 @@
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
|
||||
use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::EnqueueEditImageCommand;
|
||||
use crate::core::commands::deprecated::image_inpaint::enqueue_image_inpaint_command::EnqueueInpaintImageCommand;
|
||||
use crate::core::commands::generate::generate_video::request::{TauriGenerateVideoRequest, TauriVideoModel, GrokAspectRatio, SoraOrientation};
|
||||
use crate::core::commands::generate::generate_video::request::{TauriGenerateVideoRequest, GrokAspectRatio, SoraOrientation};
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent;
|
||||
use crate::core::events::generation_events::common::GenerationModel;
|
||||
@@ -91,16 +91,9 @@ pub async fn handle_grok_video(
|
||||
}
|
||||
};
|
||||
|
||||
// NB: Grok's consumer API doesn't take a version selector; this only affects
|
||||
// which model is recorded for the task queue / events.
|
||||
let generation_model = match request.model {
|
||||
Some(TauriVideoModel::GrokImagineVideo1p5) => GenerationModel::GrokImagineVideo1p5,
|
||||
_ => GenerationModel::GrokVideo,
|
||||
};
|
||||
|
||||
Ok(TaskEnqueueSuccess {
|
||||
provider: GenerationProvider::Grok,
|
||||
model: Some(generation_model),
|
||||
model: Some(GenerationModel::GrokVideo),
|
||||
provider_job_id: Some(post_id.to_string()),
|
||||
task_type: TaskType::VideoGeneration,
|
||||
maybe_queue_status_url: None,
|
||||
|
||||
@@ -10,27 +10,19 @@ use crate::core::commands::response::success_response_wrapper::SerializeMarker;
|
||||
|
||||
/// This is used in the Tauri command bridge.
|
||||
/// Don't change the serializations without coordinating with the frontend.
|
||||
///
|
||||
/// MIGRATION (2026-07): we're moving the frontend to send storyteller-web omni
|
||||
/// identifiers (`CommonVideoModel` serde strings). Variants whose legacy Tauri
|
||||
/// id differs carry the omni id as a `#[serde(alias)]` so BOTH deserialize.
|
||||
/// Once the frontend is 100% omni ids, the legacy renames can be dropped.
|
||||
#[derive(Deserialize, Debug, Copy, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TauriVideoModel {
|
||||
#[serde(rename = "grok_video", alias = "grok_imagine_video")]
|
||||
#[serde(rename = "grok_video")]
|
||||
GrokVideo,
|
||||
|
||||
#[serde(rename = "grok_imagine_video_1p5")]
|
||||
GrokImagineVideo1p5,
|
||||
|
||||
#[serde(rename = "kling_1.6_pro", alias = "kling_1p6_pro")]
|
||||
#[serde(rename = "kling_1.6_pro")]
|
||||
Kling16Pro,
|
||||
|
||||
#[serde(rename = "kling_2.1_pro", alias = "kling_2p1_pro")]
|
||||
#[serde(rename = "kling_2.1_pro")]
|
||||
Kling21Pro,
|
||||
|
||||
#[serde(rename = "kling_2.1_master", alias = "kling_2p1_master")]
|
||||
#[serde(rename = "kling_2.1_master")]
|
||||
Kling21Master,
|
||||
|
||||
#[serde(rename = "kling_2p5_turbo_pro")]
|
||||
@@ -48,7 +40,7 @@ pub enum TauriVideoModel {
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
#[serde(rename = "seedance_1.0_lite", alias = "seedance_1p0_lite")]
|
||||
#[serde(rename = "seedance_1.0_lite")]
|
||||
Seedance10Lite,
|
||||
|
||||
#[serde(rename = "seedance_1p5_pro")]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod generate_image;
|
||||
pub mod generate_video;
|
||||
pub mod common;
|
||||
pub mod models;
|
||||
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
//! Tauri command: list available image models.
|
||||
//!
|
||||
//! Maps the API-client response into command-specific types (see `response_types`),
|
||||
//! retries the request up to `MAX_ATTEMPTS` times, and caches successful responses
|
||||
//! in-memory for `CACHE_TTL`.
|
||||
|
||||
use std::sync::RwLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use log::{debug, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use tauri::State;
|
||||
|
||||
use artcraft_client::endpoints::omni_gen::models::image::omni_gen_list_image_models::{omni_gen_list_image_models, OmniGenListImageModelsArgs, OmniGenImageModelsResponse};
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use crate::core::commands::generate::models::image::response_types::ListImageModelsResponse;
|
||||
use crate::core::commands::response::shorthand::ResponseOrErrorMessage;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
|
||||
const MAX_ATTEMPTS: u32 = 3;
|
||||
const RETRY_BACKOFF: Duration = Duration::from_millis(250);
|
||||
const CACHE_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
struct CacheEntry {
|
||||
response: ListImageModelsResponse,
|
||||
loaded_at: Instant,
|
||||
}
|
||||
|
||||
static CACHE: Lazy<RwLock<Option<CacheEntry>>> = Lazy::new(|| RwLock::new(None));
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_image_models_command(
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
) -> ResponseOrErrorMessage<ListImageModelsResponse> {
|
||||
if let Some(cached) = cached_response() {
|
||||
debug!("list_image_models_command: serving cached response");
|
||||
return Ok(cached.into());
|
||||
}
|
||||
|
||||
match fetch_with_retry(&app_env_configs.storyteller_host).await {
|
||||
Ok(api_response) => {
|
||||
let response: ListImageModelsResponse = api_response.into();
|
||||
store_response(response.clone());
|
||||
Ok(response.into())
|
||||
}
|
||||
Err(error_message) => {
|
||||
warn!("list_image_models_command failed after {} attempts: {}", MAX_ATTEMPTS, error_message);
|
||||
if let Some(stale) = any_cached_response() {
|
||||
warn!("list_image_models_command: refresh failed; serving stale cached response");
|
||||
return Ok(stale.into());
|
||||
}
|
||||
Err(error_message.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cached_response() -> Option<ListImageModelsResponse> {
|
||||
let guard = CACHE.read().ok()?;
|
||||
let entry = guard.as_ref()?;
|
||||
if entry.loaded_at.elapsed() < CACHE_TTL {
|
||||
Some(entry.response.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached response regardless of age. Used as a fallback so that a failed
|
||||
/// refresh still returns the last-known-good data instead of an error.
|
||||
fn any_cached_response() -> Option<ListImageModelsResponse> {
|
||||
let guard = CACHE.read().ok()?;
|
||||
guard.as_ref().map(|entry| entry.response.clone())
|
||||
}
|
||||
|
||||
fn store_response(response: ListImageModelsResponse) {
|
||||
if let Ok(mut guard) = CACHE.write() {
|
||||
*guard = Some(CacheEntry { response, loaded_at: Instant::now() });
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the models list, retrying up to `MAX_ATTEMPTS` times on failure.
|
||||
async fn fetch_with_retry(api_host: &ApiHost) -> Result<OmniGenImageModelsResponse, String> {
|
||||
let mut last_error = "unknown error".to_string();
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match omni_gen_list_image_models(OmniGenListImageModelsArgs { api_host, maybe_creds: None, provider: None }).await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(err) => {
|
||||
last_error = err.to_string();
|
||||
warn!("list_image_models attempt {}/{} failed: {}", attempt, MAX_ATTEMPTS, last_error);
|
||||
if attempt < MAX_ATTEMPTS {
|
||||
tokio::time::sleep(RETRY_BACKOFF).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_error)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod list_image_models_command;
|
||||
pub mod response_types;
|
||||
@@ -1,524 +0,0 @@
|
||||
//! Command-specific response types for `list_image_models_command`.
|
||||
//!
|
||||
//! These are duplicated (never `artcraft_client` types), and every enum has an
|
||||
//! `Unknown(String)` catch-all so newer server variants are preserved instead of
|
||||
//! breaking an older desktop build. `From` impls map the API-client response over.
|
||||
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_generation_provider::ApiClientGenerationProvider;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_model_creator::ApiClientModelCreator;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_image_model::ApiClientImageModel;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_aspect_ratio::ApiClientAspectRatio;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_resolution::ApiClientResolution;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_quality::ApiClientQuality;
|
||||
use artcraft_client::endpoints::omni_gen::models::image::omni_gen_list_image_models::{
|
||||
OmniGenImageModelDetails, OmniGenImageModelProviderDetails, OmniGenImageProviderModelDetails, OmniGenImageModelsResponse,
|
||||
};
|
||||
|
||||
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
|
||||
|
||||
impl SerializeMarker for ListImageModelsResponse {}
|
||||
|
||||
// ============================ Enums ============================
|
||||
|
||||
/// Command-specific copy of `ApiClientGenerationProvider` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListImageModelsProvider {
|
||||
Artcraft,
|
||||
Fal,
|
||||
Grok,
|
||||
Midjourney,
|
||||
Sora,
|
||||
WorldLabs,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientGenerationProvider> for ListImageModelsProvider {
|
||||
fn from(value: ApiClientGenerationProvider) -> Self {
|
||||
match value {
|
||||
ApiClientGenerationProvider::Artcraft => Self::Artcraft,
|
||||
ApiClientGenerationProvider::Fal => Self::Fal,
|
||||
ApiClientGenerationProvider::Grok => Self::Grok,
|
||||
ApiClientGenerationProvider::Midjourney => Self::Midjourney,
|
||||
ApiClientGenerationProvider::Sora => Self::Sora,
|
||||
ApiClientGenerationProvider::WorldLabs => Self::WorldLabs,
|
||||
ApiClientGenerationProvider::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListImageModelsProvider> for String {
|
||||
fn from(value: ListImageModelsProvider) -> Self {
|
||||
match value {
|
||||
ListImageModelsProvider::Artcraft => "artcraft".to_string(),
|
||||
ListImageModelsProvider::Fal => "fal".to_string(),
|
||||
ListImageModelsProvider::Grok => "grok".to_string(),
|
||||
ListImageModelsProvider::Midjourney => "midjourney".to_string(),
|
||||
ListImageModelsProvider::Sora => "sora".to_string(),
|
||||
ListImageModelsProvider::WorldLabs => "world_labs".to_string(),
|
||||
ListImageModelsProvider::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientModelCreator` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListImageModelsModelCreator {
|
||||
Alibaba,
|
||||
ArtCraft,
|
||||
BlackForestLabs,
|
||||
Bytedance,
|
||||
Fal,
|
||||
Google,
|
||||
Grok,
|
||||
Hailuo,
|
||||
Higgsfield,
|
||||
Kling,
|
||||
Krea,
|
||||
Midjourney,
|
||||
OpenAi,
|
||||
OpenArt,
|
||||
Recraft,
|
||||
Replicate,
|
||||
Runway,
|
||||
Stability,
|
||||
Tencent,
|
||||
TensorArt,
|
||||
Vidu,
|
||||
WorldLabs,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientModelCreator> for ListImageModelsModelCreator {
|
||||
fn from(value: ApiClientModelCreator) -> Self {
|
||||
match value {
|
||||
ApiClientModelCreator::Alibaba => Self::Alibaba,
|
||||
ApiClientModelCreator::ArtCraft => Self::ArtCraft,
|
||||
ApiClientModelCreator::BlackForestLabs => Self::BlackForestLabs,
|
||||
ApiClientModelCreator::Bytedance => Self::Bytedance,
|
||||
ApiClientModelCreator::Fal => Self::Fal,
|
||||
ApiClientModelCreator::Google => Self::Google,
|
||||
ApiClientModelCreator::Grok => Self::Grok,
|
||||
ApiClientModelCreator::Hailuo => Self::Hailuo,
|
||||
ApiClientModelCreator::Higgsfield => Self::Higgsfield,
|
||||
ApiClientModelCreator::Kling => Self::Kling,
|
||||
ApiClientModelCreator::Krea => Self::Krea,
|
||||
ApiClientModelCreator::Midjourney => Self::Midjourney,
|
||||
ApiClientModelCreator::OpenAi => Self::OpenAi,
|
||||
ApiClientModelCreator::OpenArt => Self::OpenArt,
|
||||
ApiClientModelCreator::Recraft => Self::Recraft,
|
||||
ApiClientModelCreator::Replicate => Self::Replicate,
|
||||
ApiClientModelCreator::Runway => Self::Runway,
|
||||
ApiClientModelCreator::Stability => Self::Stability,
|
||||
ApiClientModelCreator::Tencent => Self::Tencent,
|
||||
ApiClientModelCreator::TensorArt => Self::TensorArt,
|
||||
ApiClientModelCreator::Vidu => Self::Vidu,
|
||||
ApiClientModelCreator::WorldLabs => Self::WorldLabs,
|
||||
ApiClientModelCreator::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListImageModelsModelCreator> for String {
|
||||
fn from(value: ListImageModelsModelCreator) -> Self {
|
||||
match value {
|
||||
ListImageModelsModelCreator::Alibaba => "alibaba".to_string(),
|
||||
ListImageModelsModelCreator::ArtCraft => "artcraft".to_string(),
|
||||
ListImageModelsModelCreator::BlackForestLabs => "black_forest_labs".to_string(),
|
||||
ListImageModelsModelCreator::Bytedance => "bytedance".to_string(),
|
||||
ListImageModelsModelCreator::Fal => "fal".to_string(),
|
||||
ListImageModelsModelCreator::Google => "google".to_string(),
|
||||
ListImageModelsModelCreator::Grok => "grok".to_string(),
|
||||
ListImageModelsModelCreator::Hailuo => "hailuo".to_string(),
|
||||
ListImageModelsModelCreator::Higgsfield => "higgsfield".to_string(),
|
||||
ListImageModelsModelCreator::Kling => "kling".to_string(),
|
||||
ListImageModelsModelCreator::Krea => "krea".to_string(),
|
||||
ListImageModelsModelCreator::Midjourney => "midjourney".to_string(),
|
||||
ListImageModelsModelCreator::OpenAi => "open_ai".to_string(),
|
||||
ListImageModelsModelCreator::OpenArt => "open_art".to_string(),
|
||||
ListImageModelsModelCreator::Recraft => "recraft".to_string(),
|
||||
ListImageModelsModelCreator::Replicate => "replicate".to_string(),
|
||||
ListImageModelsModelCreator::Runway => "runway".to_string(),
|
||||
ListImageModelsModelCreator::Stability => "stability".to_string(),
|
||||
ListImageModelsModelCreator::Tencent => "tencent".to_string(),
|
||||
ListImageModelsModelCreator::TensorArt => "tensor_art".to_string(),
|
||||
ListImageModelsModelCreator::Vidu => "vidu".to_string(),
|
||||
ListImageModelsModelCreator::WorldLabs => "world_labs".to_string(),
|
||||
ListImageModelsModelCreator::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientImageModel` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListImageModelsImageModel {
|
||||
Flux1Dev,
|
||||
Flux1Schnell,
|
||||
FluxPro11,
|
||||
FluxPro11Ultra,
|
||||
GptImage1,
|
||||
GptImage1p5,
|
||||
GptImage2,
|
||||
GrokImagineImage,
|
||||
GrokImagineImageQuality,
|
||||
Midjourney7,
|
||||
Midjourney7Niji,
|
||||
Midjourney8,
|
||||
NanoBanana,
|
||||
NanoBanana2,
|
||||
NanoBananaPro,
|
||||
Seedream4,
|
||||
Seedream4p5,
|
||||
Seedream5Lite,
|
||||
QwenEdit2511Angles,
|
||||
Flux2LoraAngles,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientImageModel> for ListImageModelsImageModel {
|
||||
fn from(value: ApiClientImageModel) -> Self {
|
||||
match value {
|
||||
ApiClientImageModel::Flux1Dev => Self::Flux1Dev,
|
||||
ApiClientImageModel::Flux1Schnell => Self::Flux1Schnell,
|
||||
ApiClientImageModel::FluxPro11 => Self::FluxPro11,
|
||||
ApiClientImageModel::FluxPro11Ultra => Self::FluxPro11Ultra,
|
||||
ApiClientImageModel::GptImage1 => Self::GptImage1,
|
||||
ApiClientImageModel::GptImage1p5 => Self::GptImage1p5,
|
||||
ApiClientImageModel::GptImage2 => Self::GptImage2,
|
||||
ApiClientImageModel::GrokImagineImage => Self::GrokImagineImage,
|
||||
ApiClientImageModel::GrokImagineImageQuality => Self::GrokImagineImageQuality,
|
||||
ApiClientImageModel::Midjourney7 => Self::Midjourney7,
|
||||
ApiClientImageModel::Midjourney7Niji => Self::Midjourney7Niji,
|
||||
ApiClientImageModel::Midjourney8 => Self::Midjourney8,
|
||||
ApiClientImageModel::NanoBanana => Self::NanoBanana,
|
||||
ApiClientImageModel::NanoBanana2 => Self::NanoBanana2,
|
||||
ApiClientImageModel::NanoBananaPro => Self::NanoBananaPro,
|
||||
ApiClientImageModel::Seedream4 => Self::Seedream4,
|
||||
ApiClientImageModel::Seedream4p5 => Self::Seedream4p5,
|
||||
ApiClientImageModel::Seedream5Lite => Self::Seedream5Lite,
|
||||
ApiClientImageModel::QwenEdit2511Angles => Self::QwenEdit2511Angles,
|
||||
ApiClientImageModel::Flux2LoraAngles => Self::Flux2LoraAngles,
|
||||
ApiClientImageModel::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListImageModelsImageModel> for String {
|
||||
fn from(value: ListImageModelsImageModel) -> Self {
|
||||
match value {
|
||||
ListImageModelsImageModel::Flux1Dev => "flux_1_dev".to_string(),
|
||||
ListImageModelsImageModel::Flux1Schnell => "flux_1_schnell".to_string(),
|
||||
ListImageModelsImageModel::FluxPro11 => "flux_pro_1p1".to_string(),
|
||||
ListImageModelsImageModel::FluxPro11Ultra => "flux_pro_1p1_ultra".to_string(),
|
||||
ListImageModelsImageModel::GptImage1 => "gpt_image_1".to_string(),
|
||||
ListImageModelsImageModel::GptImage1p5 => "gpt_image_1p5".to_string(),
|
||||
ListImageModelsImageModel::GptImage2 => "gpt_image_2".to_string(),
|
||||
ListImageModelsImageModel::GrokImagineImage => "grok_imagine_image".to_string(),
|
||||
ListImageModelsImageModel::GrokImagineImageQuality => "grok_imagine_image_q".to_string(),
|
||||
ListImageModelsImageModel::Midjourney7 => "midjourney_7".to_string(),
|
||||
ListImageModelsImageModel::Midjourney7Niji => "midjourney_7_niji".to_string(),
|
||||
ListImageModelsImageModel::Midjourney8 => "midjourney_8".to_string(),
|
||||
ListImageModelsImageModel::NanoBanana => "nano_banana".to_string(),
|
||||
ListImageModelsImageModel::NanoBanana2 => "nano_banana_2".to_string(),
|
||||
ListImageModelsImageModel::NanoBananaPro => "nano_banana_pro".to_string(),
|
||||
ListImageModelsImageModel::Seedream4 => "seedream_4".to_string(),
|
||||
ListImageModelsImageModel::Seedream4p5 => "seedream_4p5".to_string(),
|
||||
ListImageModelsImageModel::Seedream5Lite => "seedream_5_lite".to_string(),
|
||||
ListImageModelsImageModel::QwenEdit2511Angles => "qwen_edit_2511_angles".to_string(),
|
||||
ListImageModelsImageModel::Flux2LoraAngles => "flux_2_lora_angles".to_string(),
|
||||
ListImageModelsImageModel::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientAspectRatio` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListImageModelsAspectRatio {
|
||||
Auto,
|
||||
Square,
|
||||
WideThreeByTwo,
|
||||
WideFourByThree,
|
||||
WideFiveByFour,
|
||||
WideSixteenByNine,
|
||||
WideTwentyOneByNine,
|
||||
TallTwoByThree,
|
||||
TallThreeByFour,
|
||||
TallFourByFive,
|
||||
TallNineBySixteen,
|
||||
TallNineByTwentyOne,
|
||||
Wide,
|
||||
Tall,
|
||||
Auto2k,
|
||||
Auto3k,
|
||||
Auto4k,
|
||||
SquareHd,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientAspectRatio> for ListImageModelsAspectRatio {
|
||||
fn from(value: ApiClientAspectRatio) -> Self {
|
||||
match value {
|
||||
ApiClientAspectRatio::Auto => Self::Auto,
|
||||
ApiClientAspectRatio::Square => Self::Square,
|
||||
ApiClientAspectRatio::WideThreeByTwo => Self::WideThreeByTwo,
|
||||
ApiClientAspectRatio::WideFourByThree => Self::WideFourByThree,
|
||||
ApiClientAspectRatio::WideFiveByFour => Self::WideFiveByFour,
|
||||
ApiClientAspectRatio::WideSixteenByNine => Self::WideSixteenByNine,
|
||||
ApiClientAspectRatio::WideTwentyOneByNine => Self::WideTwentyOneByNine,
|
||||
ApiClientAspectRatio::TallTwoByThree => Self::TallTwoByThree,
|
||||
ApiClientAspectRatio::TallThreeByFour => Self::TallThreeByFour,
|
||||
ApiClientAspectRatio::TallFourByFive => Self::TallFourByFive,
|
||||
ApiClientAspectRatio::TallNineBySixteen => Self::TallNineBySixteen,
|
||||
ApiClientAspectRatio::TallNineByTwentyOne => Self::TallNineByTwentyOne,
|
||||
ApiClientAspectRatio::Wide => Self::Wide,
|
||||
ApiClientAspectRatio::Tall => Self::Tall,
|
||||
ApiClientAspectRatio::Auto2k => Self::Auto2k,
|
||||
ApiClientAspectRatio::Auto3k => Self::Auto3k,
|
||||
ApiClientAspectRatio::Auto4k => Self::Auto4k,
|
||||
ApiClientAspectRatio::SquareHd => Self::SquareHd,
|
||||
ApiClientAspectRatio::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListImageModelsAspectRatio> for String {
|
||||
fn from(value: ListImageModelsAspectRatio) -> Self {
|
||||
match value {
|
||||
ListImageModelsAspectRatio::Auto => "auto".to_string(),
|
||||
ListImageModelsAspectRatio::Square => "square".to_string(),
|
||||
ListImageModelsAspectRatio::WideThreeByTwo => "wide_three_by_two".to_string(),
|
||||
ListImageModelsAspectRatio::WideFourByThree => "wide_four_by_three".to_string(),
|
||||
ListImageModelsAspectRatio::WideFiveByFour => "wide_five_by_four".to_string(),
|
||||
ListImageModelsAspectRatio::WideSixteenByNine => "wide_sixteen_by_nine".to_string(),
|
||||
ListImageModelsAspectRatio::WideTwentyOneByNine => "wide_twenty_one_by_nine".to_string(),
|
||||
ListImageModelsAspectRatio::TallTwoByThree => "tall_two_by_three".to_string(),
|
||||
ListImageModelsAspectRatio::TallThreeByFour => "tall_three_by_four".to_string(),
|
||||
ListImageModelsAspectRatio::TallFourByFive => "tall_four_by_five".to_string(),
|
||||
ListImageModelsAspectRatio::TallNineBySixteen => "tall_nine_by_sixteen".to_string(),
|
||||
ListImageModelsAspectRatio::TallNineByTwentyOne => "tall_nine_by_twenty_one".to_string(),
|
||||
ListImageModelsAspectRatio::Wide => "wide".to_string(),
|
||||
ListImageModelsAspectRatio::Tall => "tall".to_string(),
|
||||
ListImageModelsAspectRatio::Auto2k => "auto_2k".to_string(),
|
||||
ListImageModelsAspectRatio::Auto3k => "auto_3k".to_string(),
|
||||
ListImageModelsAspectRatio::Auto4k => "auto_4k".to_string(),
|
||||
ListImageModelsAspectRatio::SquareHd => "square_hd".to_string(),
|
||||
ListImageModelsAspectRatio::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientResolution` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListImageModelsResolution {
|
||||
OneK,
|
||||
TwoK,
|
||||
ThreeK,
|
||||
FourK,
|
||||
HalfK,
|
||||
FourEightyP,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientResolution> for ListImageModelsResolution {
|
||||
fn from(value: ApiClientResolution) -> Self {
|
||||
match value {
|
||||
ApiClientResolution::OneK => Self::OneK,
|
||||
ApiClientResolution::TwoK => Self::TwoK,
|
||||
ApiClientResolution::ThreeK => Self::ThreeK,
|
||||
ApiClientResolution::FourK => Self::FourK,
|
||||
ApiClientResolution::HalfK => Self::HalfK,
|
||||
ApiClientResolution::FourEightyP => Self::FourEightyP,
|
||||
ApiClientResolution::SevenTwentyP => Self::SevenTwentyP,
|
||||
ApiClientResolution::TenEightyP => Self::TenEightyP,
|
||||
ApiClientResolution::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListImageModelsResolution> for String {
|
||||
fn from(value: ListImageModelsResolution) -> Self {
|
||||
match value {
|
||||
ListImageModelsResolution::OneK => "one_k".to_string(),
|
||||
ListImageModelsResolution::TwoK => "two_k".to_string(),
|
||||
ListImageModelsResolution::ThreeK => "three_k".to_string(),
|
||||
ListImageModelsResolution::FourK => "four_k".to_string(),
|
||||
ListImageModelsResolution::HalfK => "half_k".to_string(),
|
||||
ListImageModelsResolution::FourEightyP => "four_eighty_p".to_string(),
|
||||
ListImageModelsResolution::SevenTwentyP => "seven_twenty_p".to_string(),
|
||||
ListImageModelsResolution::TenEightyP => "ten_eighty_p".to_string(),
|
||||
ListImageModelsResolution::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientQuality` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListImageModelsQuality {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientQuality> for ListImageModelsQuality {
|
||||
fn from(value: ApiClientQuality) -> Self {
|
||||
match value {
|
||||
ApiClientQuality::High => Self::High,
|
||||
ApiClientQuality::Medium => Self::Medium,
|
||||
ApiClientQuality::Low => Self::Low,
|
||||
ApiClientQuality::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListImageModelsQuality> for String {
|
||||
fn from(value: ListImageModelsQuality) -> Self {
|
||||
match value {
|
||||
ListImageModelsQuality::High => "high".to_string(),
|
||||
ListImageModelsQuality::Medium => "medium".to_string(),
|
||||
ListImageModelsQuality::Low => "low".to_string(),
|
||||
ListImageModelsQuality::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ Structs ============================
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ListImageModelsResponse {
|
||||
pub success: bool,
|
||||
pub models: Vec<ListImageModelsModelDetails>,
|
||||
pub providers: Vec<ListImageModelsProviderDetails>,
|
||||
}
|
||||
|
||||
impl From<OmniGenImageModelsResponse> for ListImageModelsResponse {
|
||||
fn from(v: OmniGenImageModelsResponse) -> Self {
|
||||
Self {
|
||||
success: v.success,
|
||||
models: v.models.into_iter().map(Into::into).collect(),
|
||||
providers: v.providers.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ListImageModelsProviderDetails {
|
||||
pub provider: ListImageModelsProvider,
|
||||
pub models: Vec<ListImageModelsProviderModelDetails>,
|
||||
}
|
||||
|
||||
impl From<OmniGenImageModelProviderDetails> for ListImageModelsProviderDetails {
|
||||
fn from(v: OmniGenImageModelProviderDetails) -> Self {
|
||||
Self {
|
||||
provider: v.provider.into(),
|
||||
models: v.models.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ListImageModelsProviderModelDetails {
|
||||
pub model: ListImageModelsImageModel,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overrides: Option<ListImageModelsModelDetails>,
|
||||
}
|
||||
|
||||
impl From<OmniGenImageProviderModelDetails> for ListImageModelsProviderModelDetails {
|
||||
fn from(v: OmniGenImageProviderModelDetails) -> Self {
|
||||
Self {
|
||||
model: v.model.into(),
|
||||
overrides: v.overrides.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ListImageModelsModelDetails {
|
||||
pub model: ListImageModelsImageModel,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_creator: Option<ListImageModelsModelCreator>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub full_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_prompt_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_prompt_max_length: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub negative_text_prompt_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub negative_text_prompt_max_length: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_refs_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_refs_max: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub has_fixed_editing_aspect_ratio: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio_options: Option<Vec<ListImageModelsAspectRatio>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio_default: Option<ListImageModelsAspectRatio>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio_default_when_editing: Option<ListImageModelsAspectRatio>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution_options: Option<Vec<ListImageModelsResolution>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution_default: Option<ListImageModelsResolution>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality_options: Option<Vec<ListImageModelsQuality>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_quality: Option<ListImageModelsQuality>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_min: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_max: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_options: Option<Vec<u16>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_default: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<OmniGenImageModelDetails> for ListImageModelsModelDetails {
|
||||
fn from(v: OmniGenImageModelDetails) -> Self {
|
||||
Self {
|
||||
model: v.model.into(),
|
||||
model_creator: v.model_creator.map(Into::into),
|
||||
full_name: v.full_name,
|
||||
text_prompt_supported: v.text_prompt_supported,
|
||||
text_prompt_max_length: v.text_prompt_max_length,
|
||||
negative_text_prompt_supported: v.negative_text_prompt_supported,
|
||||
negative_text_prompt_max_length: v.negative_text_prompt_max_length,
|
||||
image_refs_supported: v.image_refs_supported,
|
||||
image_refs_max: v.image_refs_max,
|
||||
has_fixed_editing_aspect_ratio: v.has_fixed_editing_aspect_ratio,
|
||||
aspect_ratio_options: v.aspect_ratio_options.map(|items| items.into_iter().map(Into::into).collect()),
|
||||
aspect_ratio_default: v.aspect_ratio_default.map(Into::into),
|
||||
aspect_ratio_default_when_editing: v.aspect_ratio_default_when_editing.map(Into::into),
|
||||
resolution_options: v.resolution_options.map(|items| items.into_iter().map(Into::into).collect()),
|
||||
resolution_default: v.resolution_default.map(Into::into),
|
||||
quality_options: v.quality_options.map(|items| items.into_iter().map(Into::into).collect()),
|
||||
default_quality: v.default_quality.map(Into::into),
|
||||
batch_size_min: v.batch_size_min,
|
||||
batch_size_max: v.batch_size_max,
|
||||
batch_size_options: v.batch_size_options,
|
||||
batch_size_default: v.batch_size_default,
|
||||
is_disabled: v.is_disabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod image;
|
||||
pub mod video;
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
//! Tauri command: list available video models.
|
||||
//!
|
||||
//! Maps the API-client response into command-specific types (see `response_types`),
|
||||
//! retries the request up to `MAX_ATTEMPTS` times, and caches successful responses
|
||||
//! in-memory for `CACHE_TTL`.
|
||||
|
||||
use std::sync::RwLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use log::{debug, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use tauri::State;
|
||||
|
||||
use artcraft_client::endpoints::omni_gen::models::video::omni_gen_list_video_models::{omni_gen_list_video_models, OmniGenListVideoModelsArgs, OmniGenVideoModelsResponse};
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use crate::core::commands::generate::models::video::response_types::ListVideoModelsResponse;
|
||||
use crate::core::commands::response::shorthand::ResponseOrErrorMessage;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
|
||||
const MAX_ATTEMPTS: u32 = 3;
|
||||
const RETRY_BACKOFF: Duration = Duration::from_millis(250);
|
||||
const CACHE_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
struct CacheEntry {
|
||||
response: ListVideoModelsResponse,
|
||||
loaded_at: Instant,
|
||||
}
|
||||
|
||||
static CACHE: Lazy<RwLock<Option<CacheEntry>>> = Lazy::new(|| RwLock::new(None));
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_video_models_command(
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
) -> ResponseOrErrorMessage<ListVideoModelsResponse> {
|
||||
if let Some(cached) = cached_response() {
|
||||
debug!("list_video_models_command: serving cached response");
|
||||
return Ok(cached.into());
|
||||
}
|
||||
|
||||
match fetch_with_retry(&app_env_configs.storyteller_host).await {
|
||||
Ok(api_response) => {
|
||||
let response: ListVideoModelsResponse = api_response.into();
|
||||
store_response(response.clone());
|
||||
Ok(response.into())
|
||||
}
|
||||
Err(error_message) => {
|
||||
warn!("list_video_models_command failed after {} attempts: {}", MAX_ATTEMPTS, error_message);
|
||||
if let Some(stale) = any_cached_response() {
|
||||
warn!("list_video_models_command: refresh failed; serving stale cached response");
|
||||
return Ok(stale.into());
|
||||
}
|
||||
Err(error_message.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cached_response() -> Option<ListVideoModelsResponse> {
|
||||
let guard = CACHE.read().ok()?;
|
||||
let entry = guard.as_ref()?;
|
||||
if entry.loaded_at.elapsed() < CACHE_TTL {
|
||||
Some(entry.response.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached response regardless of age. Used as a fallback so that a failed
|
||||
/// refresh still returns the last-known-good data instead of an error.
|
||||
fn any_cached_response() -> Option<ListVideoModelsResponse> {
|
||||
let guard = CACHE.read().ok()?;
|
||||
guard.as_ref().map(|entry| entry.response.clone())
|
||||
}
|
||||
|
||||
fn store_response(response: ListVideoModelsResponse) {
|
||||
if let Ok(mut guard) = CACHE.write() {
|
||||
*guard = Some(CacheEntry { response, loaded_at: Instant::now() });
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the models list, retrying up to `MAX_ATTEMPTS` times on failure.
|
||||
async fn fetch_with_retry(api_host: &ApiHost) -> Result<OmniGenVideoModelsResponse, String> {
|
||||
let mut last_error = "unknown error".to_string();
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match omni_gen_list_video_models(OmniGenListVideoModelsArgs { api_host, maybe_creds: None, provider: None }).await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(err) => {
|
||||
last_error = err.to_string();
|
||||
warn!("list_video_models attempt {}/{} failed: {}", attempt, MAX_ATTEMPTS, last_error);
|
||||
if attempt < MAX_ATTEMPTS {
|
||||
tokio::time::sleep(RETRY_BACKOFF).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_error)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod list_video_models_command;
|
||||
pub mod response_types;
|
||||
@@ -1,654 +0,0 @@
|
||||
//! Command-specific response types for `list_video_models_command`.
|
||||
//!
|
||||
//! These are duplicated (never `artcraft_client` types), and every enum has an
|
||||
//! `Unknown(String)` catch-all so newer server variants are preserved instead of
|
||||
//! breaking an older desktop build. `From` impls map the API-client response over.
|
||||
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_generation_provider::ApiClientGenerationProvider;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_model_creator::ApiClientModelCreator;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_video_model::ApiClientVideoModel;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_aspect_ratio::ApiClientAspectRatio;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_resolution::ApiClientResolution;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_bitrate::ApiClientBitrate;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_quality::ApiClientQuality;
|
||||
use artcraft_client::endpoints::omni_gen::models::video::omni_gen_list_video_models::{
|
||||
OmniGenVideoModelDetails, OmniGenVideoModelProviderDetails, OmniGenVideoProviderModelDetails, OmniGenVideoModelsResponse,
|
||||
};
|
||||
|
||||
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
|
||||
|
||||
impl SerializeMarker for ListVideoModelsResponse {}
|
||||
|
||||
// ============================ Enums ============================
|
||||
|
||||
/// Command-specific copy of `ApiClientGenerationProvider` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListVideoModelsProvider {
|
||||
Artcraft,
|
||||
Fal,
|
||||
Grok,
|
||||
Midjourney,
|
||||
Sora,
|
||||
WorldLabs,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientGenerationProvider> for ListVideoModelsProvider {
|
||||
fn from(value: ApiClientGenerationProvider) -> Self {
|
||||
match value {
|
||||
ApiClientGenerationProvider::Artcraft => Self::Artcraft,
|
||||
ApiClientGenerationProvider::Fal => Self::Fal,
|
||||
ApiClientGenerationProvider::Grok => Self::Grok,
|
||||
ApiClientGenerationProvider::Midjourney => Self::Midjourney,
|
||||
ApiClientGenerationProvider::Sora => Self::Sora,
|
||||
ApiClientGenerationProvider::WorldLabs => Self::WorldLabs,
|
||||
ApiClientGenerationProvider::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListVideoModelsProvider> for String {
|
||||
fn from(value: ListVideoModelsProvider) -> Self {
|
||||
match value {
|
||||
ListVideoModelsProvider::Artcraft => "artcraft".to_string(),
|
||||
ListVideoModelsProvider::Fal => "fal".to_string(),
|
||||
ListVideoModelsProvider::Grok => "grok".to_string(),
|
||||
ListVideoModelsProvider::Midjourney => "midjourney".to_string(),
|
||||
ListVideoModelsProvider::Sora => "sora".to_string(),
|
||||
ListVideoModelsProvider::WorldLabs => "world_labs".to_string(),
|
||||
ListVideoModelsProvider::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientModelCreator` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListVideoModelsModelCreator {
|
||||
Alibaba,
|
||||
ArtCraft,
|
||||
BlackForestLabs,
|
||||
Bytedance,
|
||||
Fal,
|
||||
Google,
|
||||
Grok,
|
||||
Hailuo,
|
||||
Higgsfield,
|
||||
Kling,
|
||||
Krea,
|
||||
Midjourney,
|
||||
OpenAi,
|
||||
OpenArt,
|
||||
Recraft,
|
||||
Replicate,
|
||||
Runway,
|
||||
Stability,
|
||||
Tencent,
|
||||
TensorArt,
|
||||
Vidu,
|
||||
WorldLabs,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientModelCreator> for ListVideoModelsModelCreator {
|
||||
fn from(value: ApiClientModelCreator) -> Self {
|
||||
match value {
|
||||
ApiClientModelCreator::Alibaba => Self::Alibaba,
|
||||
ApiClientModelCreator::ArtCraft => Self::ArtCraft,
|
||||
ApiClientModelCreator::BlackForestLabs => Self::BlackForestLabs,
|
||||
ApiClientModelCreator::Bytedance => Self::Bytedance,
|
||||
ApiClientModelCreator::Fal => Self::Fal,
|
||||
ApiClientModelCreator::Google => Self::Google,
|
||||
ApiClientModelCreator::Grok => Self::Grok,
|
||||
ApiClientModelCreator::Hailuo => Self::Hailuo,
|
||||
ApiClientModelCreator::Higgsfield => Self::Higgsfield,
|
||||
ApiClientModelCreator::Kling => Self::Kling,
|
||||
ApiClientModelCreator::Krea => Self::Krea,
|
||||
ApiClientModelCreator::Midjourney => Self::Midjourney,
|
||||
ApiClientModelCreator::OpenAi => Self::OpenAi,
|
||||
ApiClientModelCreator::OpenArt => Self::OpenArt,
|
||||
ApiClientModelCreator::Recraft => Self::Recraft,
|
||||
ApiClientModelCreator::Replicate => Self::Replicate,
|
||||
ApiClientModelCreator::Runway => Self::Runway,
|
||||
ApiClientModelCreator::Stability => Self::Stability,
|
||||
ApiClientModelCreator::Tencent => Self::Tencent,
|
||||
ApiClientModelCreator::TensorArt => Self::TensorArt,
|
||||
ApiClientModelCreator::Vidu => Self::Vidu,
|
||||
ApiClientModelCreator::WorldLabs => Self::WorldLabs,
|
||||
ApiClientModelCreator::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListVideoModelsModelCreator> for String {
|
||||
fn from(value: ListVideoModelsModelCreator) -> Self {
|
||||
match value {
|
||||
ListVideoModelsModelCreator::Alibaba => "alibaba".to_string(),
|
||||
ListVideoModelsModelCreator::ArtCraft => "artcraft".to_string(),
|
||||
ListVideoModelsModelCreator::BlackForestLabs => "black_forest_labs".to_string(),
|
||||
ListVideoModelsModelCreator::Bytedance => "bytedance".to_string(),
|
||||
ListVideoModelsModelCreator::Fal => "fal".to_string(),
|
||||
ListVideoModelsModelCreator::Google => "google".to_string(),
|
||||
ListVideoModelsModelCreator::Grok => "grok".to_string(),
|
||||
ListVideoModelsModelCreator::Hailuo => "hailuo".to_string(),
|
||||
ListVideoModelsModelCreator::Higgsfield => "higgsfield".to_string(),
|
||||
ListVideoModelsModelCreator::Kling => "kling".to_string(),
|
||||
ListVideoModelsModelCreator::Krea => "krea".to_string(),
|
||||
ListVideoModelsModelCreator::Midjourney => "midjourney".to_string(),
|
||||
ListVideoModelsModelCreator::OpenAi => "open_ai".to_string(),
|
||||
ListVideoModelsModelCreator::OpenArt => "open_art".to_string(),
|
||||
ListVideoModelsModelCreator::Recraft => "recraft".to_string(),
|
||||
ListVideoModelsModelCreator::Replicate => "replicate".to_string(),
|
||||
ListVideoModelsModelCreator::Runway => "runway".to_string(),
|
||||
ListVideoModelsModelCreator::Stability => "stability".to_string(),
|
||||
ListVideoModelsModelCreator::Tencent => "tencent".to_string(),
|
||||
ListVideoModelsModelCreator::TensorArt => "tensor_art".to_string(),
|
||||
ListVideoModelsModelCreator::Vidu => "vidu".to_string(),
|
||||
ListVideoModelsModelCreator::WorldLabs => "world_labs".to_string(),
|
||||
ListVideoModelsModelCreator::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientVideoModel` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListVideoModelsVideoModel {
|
||||
GrokVideo,
|
||||
GrokImagineVideo,
|
||||
GrokImagineVideo1p5,
|
||||
Kling16Pro,
|
||||
Kling21Pro,
|
||||
Kling21Master,
|
||||
Kling2p5TurboPro,
|
||||
Kling2p6Pro,
|
||||
Kling3p0Standard,
|
||||
Kling3p0Pro,
|
||||
HappyHorse1p0,
|
||||
Seedance10Lite,
|
||||
Seedance1p5Pro,
|
||||
Seedance2p0,
|
||||
Seedance2p0Fast,
|
||||
Seedance2p0BytePlus,
|
||||
Seedance2p0BytePlusFast,
|
||||
Seedance2p0Ultra,
|
||||
Seedance2p0UltraFast,
|
||||
Seedance2p0BytePlusUltra,
|
||||
Seedance2p0BytePlusUltraFast,
|
||||
Seedance2p0Mini,
|
||||
Seedance2p0BytePlusMini,
|
||||
Seedance2p0BytePlusUltraMini,
|
||||
Sora2,
|
||||
Sora2Pro,
|
||||
Veo2,
|
||||
Veo3,
|
||||
Veo3Fast,
|
||||
Veo3p1,
|
||||
Veo3p1Fast,
|
||||
PreviewModel,
|
||||
PreviewModelFast,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientVideoModel> for ListVideoModelsVideoModel {
|
||||
fn from(value: ApiClientVideoModel) -> Self {
|
||||
match value {
|
||||
ApiClientVideoModel::GrokVideo => Self::GrokVideo,
|
||||
ApiClientVideoModel::GrokImagineVideo => Self::GrokImagineVideo,
|
||||
ApiClientVideoModel::GrokImagineVideo1p5 => Self::GrokImagineVideo1p5,
|
||||
ApiClientVideoModel::Kling16Pro => Self::Kling16Pro,
|
||||
ApiClientVideoModel::Kling21Pro => Self::Kling21Pro,
|
||||
ApiClientVideoModel::Kling21Master => Self::Kling21Master,
|
||||
ApiClientVideoModel::Kling2p5TurboPro => Self::Kling2p5TurboPro,
|
||||
ApiClientVideoModel::Kling2p6Pro => Self::Kling2p6Pro,
|
||||
ApiClientVideoModel::Kling3p0Standard => Self::Kling3p0Standard,
|
||||
ApiClientVideoModel::Kling3p0Pro => Self::Kling3p0Pro,
|
||||
ApiClientVideoModel::HappyHorse1p0 => Self::HappyHorse1p0,
|
||||
ApiClientVideoModel::Seedance10Lite => Self::Seedance10Lite,
|
||||
ApiClientVideoModel::Seedance1p5Pro => Self::Seedance1p5Pro,
|
||||
ApiClientVideoModel::Seedance2p0 => Self::Seedance2p0,
|
||||
ApiClientVideoModel::Seedance2p0Fast => Self::Seedance2p0Fast,
|
||||
ApiClientVideoModel::Seedance2p0BytePlus => Self::Seedance2p0BytePlus,
|
||||
ApiClientVideoModel::Seedance2p0BytePlusFast => Self::Seedance2p0BytePlusFast,
|
||||
ApiClientVideoModel::Seedance2p0Ultra => Self::Seedance2p0Ultra,
|
||||
ApiClientVideoModel::Seedance2p0UltraFast => Self::Seedance2p0UltraFast,
|
||||
ApiClientVideoModel::Seedance2p0BytePlusUltra => Self::Seedance2p0BytePlusUltra,
|
||||
ApiClientVideoModel::Seedance2p0BytePlusUltraFast => Self::Seedance2p0BytePlusUltraFast,
|
||||
ApiClientVideoModel::Seedance2p0Mini => Self::Seedance2p0Mini,
|
||||
ApiClientVideoModel::Seedance2p0BytePlusMini => Self::Seedance2p0BytePlusMini,
|
||||
ApiClientVideoModel::Seedance2p0BytePlusUltraMini => Self::Seedance2p0BytePlusUltraMini,
|
||||
ApiClientVideoModel::Sora2 => Self::Sora2,
|
||||
ApiClientVideoModel::Sora2Pro => Self::Sora2Pro,
|
||||
ApiClientVideoModel::Veo2 => Self::Veo2,
|
||||
ApiClientVideoModel::Veo3 => Self::Veo3,
|
||||
ApiClientVideoModel::Veo3Fast => Self::Veo3Fast,
|
||||
ApiClientVideoModel::Veo3p1 => Self::Veo3p1,
|
||||
ApiClientVideoModel::Veo3p1Fast => Self::Veo3p1Fast,
|
||||
ApiClientVideoModel::PreviewModel => Self::PreviewModel,
|
||||
ApiClientVideoModel::PreviewModelFast => Self::PreviewModelFast,
|
||||
ApiClientVideoModel::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListVideoModelsVideoModel> for String {
|
||||
fn from(value: ListVideoModelsVideoModel) -> Self {
|
||||
match value {
|
||||
ListVideoModelsVideoModel::GrokVideo => "grok_video".to_string(),
|
||||
ListVideoModelsVideoModel::GrokImagineVideo => "grok_imagine_video".to_string(),
|
||||
ListVideoModelsVideoModel::GrokImagineVideo1p5 => "grok_imagine_video_1p5".to_string(),
|
||||
ListVideoModelsVideoModel::Kling16Pro => "kling_1p6_pro".to_string(),
|
||||
ListVideoModelsVideoModel::Kling21Pro => "kling_2p1_pro".to_string(),
|
||||
ListVideoModelsVideoModel::Kling21Master => "kling_2p1_master".to_string(),
|
||||
ListVideoModelsVideoModel::Kling2p5TurboPro => "kling_2p5_turbo_pro".to_string(),
|
||||
ListVideoModelsVideoModel::Kling2p6Pro => "kling_2p6_pro".to_string(),
|
||||
ListVideoModelsVideoModel::Kling3p0Standard => "kling_3p0_standard".to_string(),
|
||||
ListVideoModelsVideoModel::Kling3p0Pro => "kling_3p0_pro".to_string(),
|
||||
ListVideoModelsVideoModel::HappyHorse1p0 => "happy_horse_1p0".to_string(),
|
||||
ListVideoModelsVideoModel::Seedance10Lite => "seedance_1p0_lite".to_string(),
|
||||
ListVideoModelsVideoModel::Seedance1p5Pro => "seedance_1p5_pro".to_string(),
|
||||
ListVideoModelsVideoModel::Seedance2p0 => "seedance_2p0".to_string(),
|
||||
ListVideoModelsVideoModel::Seedance2p0Fast => "seedance_2p0_fast".to_string(),
|
||||
ListVideoModelsVideoModel::Seedance2p0BytePlus => "seedance_2p0_bp".to_string(),
|
||||
ListVideoModelsVideoModel::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast".to_string(),
|
||||
ListVideoModelsVideoModel::Seedance2p0Ultra => "seedance_2p0_u".to_string(),
|
||||
ListVideoModelsVideoModel::Seedance2p0UltraFast => "seedance_2p0_u_fast".to_string(),
|
||||
ListVideoModelsVideoModel::Seedance2p0BytePlusUltra => "seedance_2p0_bpu".to_string(),
|
||||
ListVideoModelsVideoModel::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast".to_string(),
|
||||
ListVideoModelsVideoModel::Seedance2p0Mini => "seedance_2p0_mini".to_string(),
|
||||
ListVideoModelsVideoModel::Seedance2p0BytePlusMini => "seedance_2p0_bp_mini".to_string(),
|
||||
ListVideoModelsVideoModel::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini".to_string(),
|
||||
ListVideoModelsVideoModel::Sora2 => "sora_2".to_string(),
|
||||
ListVideoModelsVideoModel::Sora2Pro => "sora_2_pro".to_string(),
|
||||
ListVideoModelsVideoModel::Veo2 => "veo_2".to_string(),
|
||||
ListVideoModelsVideoModel::Veo3 => "veo_3".to_string(),
|
||||
ListVideoModelsVideoModel::Veo3Fast => "veo_3_fast".to_string(),
|
||||
ListVideoModelsVideoModel::Veo3p1 => "veo_3p1".to_string(),
|
||||
ListVideoModelsVideoModel::Veo3p1Fast => "veo_3p1_fast".to_string(),
|
||||
ListVideoModelsVideoModel::PreviewModel => "preview_model".to_string(),
|
||||
ListVideoModelsVideoModel::PreviewModelFast => "preview_model_fast".to_string(),
|
||||
ListVideoModelsVideoModel::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientAspectRatio` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListVideoModelsAspectRatio {
|
||||
Auto,
|
||||
Square,
|
||||
WideThreeByTwo,
|
||||
WideFourByThree,
|
||||
WideFiveByFour,
|
||||
WideSixteenByNine,
|
||||
WideTwentyOneByNine,
|
||||
TallTwoByThree,
|
||||
TallThreeByFour,
|
||||
TallFourByFive,
|
||||
TallNineBySixteen,
|
||||
TallNineByTwentyOne,
|
||||
Wide,
|
||||
Tall,
|
||||
Auto2k,
|
||||
Auto3k,
|
||||
Auto4k,
|
||||
SquareHd,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientAspectRatio> for ListVideoModelsAspectRatio {
|
||||
fn from(value: ApiClientAspectRatio) -> Self {
|
||||
match value {
|
||||
ApiClientAspectRatio::Auto => Self::Auto,
|
||||
ApiClientAspectRatio::Square => Self::Square,
|
||||
ApiClientAspectRatio::WideThreeByTwo => Self::WideThreeByTwo,
|
||||
ApiClientAspectRatio::WideFourByThree => Self::WideFourByThree,
|
||||
ApiClientAspectRatio::WideFiveByFour => Self::WideFiveByFour,
|
||||
ApiClientAspectRatio::WideSixteenByNine => Self::WideSixteenByNine,
|
||||
ApiClientAspectRatio::WideTwentyOneByNine => Self::WideTwentyOneByNine,
|
||||
ApiClientAspectRatio::TallTwoByThree => Self::TallTwoByThree,
|
||||
ApiClientAspectRatio::TallThreeByFour => Self::TallThreeByFour,
|
||||
ApiClientAspectRatio::TallFourByFive => Self::TallFourByFive,
|
||||
ApiClientAspectRatio::TallNineBySixteen => Self::TallNineBySixteen,
|
||||
ApiClientAspectRatio::TallNineByTwentyOne => Self::TallNineByTwentyOne,
|
||||
ApiClientAspectRatio::Wide => Self::Wide,
|
||||
ApiClientAspectRatio::Tall => Self::Tall,
|
||||
ApiClientAspectRatio::Auto2k => Self::Auto2k,
|
||||
ApiClientAspectRatio::Auto3k => Self::Auto3k,
|
||||
ApiClientAspectRatio::Auto4k => Self::Auto4k,
|
||||
ApiClientAspectRatio::SquareHd => Self::SquareHd,
|
||||
ApiClientAspectRatio::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListVideoModelsAspectRatio> for String {
|
||||
fn from(value: ListVideoModelsAspectRatio) -> Self {
|
||||
match value {
|
||||
ListVideoModelsAspectRatio::Auto => "auto".to_string(),
|
||||
ListVideoModelsAspectRatio::Square => "square".to_string(),
|
||||
ListVideoModelsAspectRatio::WideThreeByTwo => "wide_three_by_two".to_string(),
|
||||
ListVideoModelsAspectRatio::WideFourByThree => "wide_four_by_three".to_string(),
|
||||
ListVideoModelsAspectRatio::WideFiveByFour => "wide_five_by_four".to_string(),
|
||||
ListVideoModelsAspectRatio::WideSixteenByNine => "wide_sixteen_by_nine".to_string(),
|
||||
ListVideoModelsAspectRatio::WideTwentyOneByNine => "wide_twenty_one_by_nine".to_string(),
|
||||
ListVideoModelsAspectRatio::TallTwoByThree => "tall_two_by_three".to_string(),
|
||||
ListVideoModelsAspectRatio::TallThreeByFour => "tall_three_by_four".to_string(),
|
||||
ListVideoModelsAspectRatio::TallFourByFive => "tall_four_by_five".to_string(),
|
||||
ListVideoModelsAspectRatio::TallNineBySixteen => "tall_nine_by_sixteen".to_string(),
|
||||
ListVideoModelsAspectRatio::TallNineByTwentyOne => "tall_nine_by_twenty_one".to_string(),
|
||||
ListVideoModelsAspectRatio::Wide => "wide".to_string(),
|
||||
ListVideoModelsAspectRatio::Tall => "tall".to_string(),
|
||||
ListVideoModelsAspectRatio::Auto2k => "auto_2k".to_string(),
|
||||
ListVideoModelsAspectRatio::Auto3k => "auto_3k".to_string(),
|
||||
ListVideoModelsAspectRatio::Auto4k => "auto_4k".to_string(),
|
||||
ListVideoModelsAspectRatio::SquareHd => "square_hd".to_string(),
|
||||
ListVideoModelsAspectRatio::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientResolution` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListVideoModelsResolution {
|
||||
OneK,
|
||||
TwoK,
|
||||
ThreeK,
|
||||
FourK,
|
||||
HalfK,
|
||||
FourEightyP,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientResolution> for ListVideoModelsResolution {
|
||||
fn from(value: ApiClientResolution) -> Self {
|
||||
match value {
|
||||
ApiClientResolution::OneK => Self::OneK,
|
||||
ApiClientResolution::TwoK => Self::TwoK,
|
||||
ApiClientResolution::ThreeK => Self::ThreeK,
|
||||
ApiClientResolution::FourK => Self::FourK,
|
||||
ApiClientResolution::HalfK => Self::HalfK,
|
||||
ApiClientResolution::FourEightyP => Self::FourEightyP,
|
||||
ApiClientResolution::SevenTwentyP => Self::SevenTwentyP,
|
||||
ApiClientResolution::TenEightyP => Self::TenEightyP,
|
||||
ApiClientResolution::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListVideoModelsResolution> for String {
|
||||
fn from(value: ListVideoModelsResolution) -> Self {
|
||||
match value {
|
||||
ListVideoModelsResolution::OneK => "one_k".to_string(),
|
||||
ListVideoModelsResolution::TwoK => "two_k".to_string(),
|
||||
ListVideoModelsResolution::ThreeK => "three_k".to_string(),
|
||||
ListVideoModelsResolution::FourK => "four_k".to_string(),
|
||||
ListVideoModelsResolution::HalfK => "half_k".to_string(),
|
||||
ListVideoModelsResolution::FourEightyP => "four_eighty_p".to_string(),
|
||||
ListVideoModelsResolution::SevenTwentyP => "seven_twenty_p".to_string(),
|
||||
ListVideoModelsResolution::TenEightyP => "ten_eighty_p".to_string(),
|
||||
ListVideoModelsResolution::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientBitrate` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListVideoModelsBitrate {
|
||||
Normal,
|
||||
High,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientBitrate> for ListVideoModelsBitrate {
|
||||
fn from(value: ApiClientBitrate) -> Self {
|
||||
match value {
|
||||
ApiClientBitrate::Normal => Self::Normal,
|
||||
ApiClientBitrate::High => Self::High,
|
||||
ApiClientBitrate::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListVideoModelsBitrate> for String {
|
||||
fn from(value: ListVideoModelsBitrate) -> Self {
|
||||
match value {
|
||||
ListVideoModelsBitrate::Normal => "normal".to_string(),
|
||||
ListVideoModelsBitrate::High => "high".to_string(),
|
||||
ListVideoModelsBitrate::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientQuality` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListVideoModelsQuality {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientQuality> for ListVideoModelsQuality {
|
||||
fn from(value: ApiClientQuality) -> Self {
|
||||
match value {
|
||||
ApiClientQuality::High => Self::High,
|
||||
ApiClientQuality::Medium => Self::Medium,
|
||||
ApiClientQuality::Low => Self::Low,
|
||||
ApiClientQuality::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListVideoModelsQuality> for String {
|
||||
fn from(value: ListVideoModelsQuality) -> Self {
|
||||
match value {
|
||||
ListVideoModelsQuality::High => "high".to_string(),
|
||||
ListVideoModelsQuality::Medium => "medium".to_string(),
|
||||
ListVideoModelsQuality::Low => "low".to_string(),
|
||||
ListVideoModelsQuality::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ Structs ============================
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ListVideoModelsResponse {
|
||||
pub success: bool,
|
||||
pub models: Vec<ListVideoModelsModelDetails>,
|
||||
pub providers: Vec<ListVideoModelsProviderDetails>,
|
||||
}
|
||||
|
||||
impl From<OmniGenVideoModelsResponse> for ListVideoModelsResponse {
|
||||
fn from(v: OmniGenVideoModelsResponse) -> Self {
|
||||
Self {
|
||||
success: v.success,
|
||||
models: v.models.into_iter().map(Into::into).collect(),
|
||||
providers: v.providers.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ListVideoModelsProviderDetails {
|
||||
pub provider: ListVideoModelsProvider,
|
||||
pub models: Vec<ListVideoModelsProviderModelDetails>,
|
||||
}
|
||||
|
||||
impl From<OmniGenVideoModelProviderDetails> for ListVideoModelsProviderDetails {
|
||||
fn from(v: OmniGenVideoModelProviderDetails) -> Self {
|
||||
Self {
|
||||
provider: v.provider.into(),
|
||||
models: v.models.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ListVideoModelsProviderModelDetails {
|
||||
pub model: ListVideoModelsVideoModel,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overrides: Option<ListVideoModelsModelDetails>,
|
||||
}
|
||||
|
||||
impl From<OmniGenVideoProviderModelDetails> for ListVideoModelsProviderModelDetails {
|
||||
fn from(v: OmniGenVideoProviderModelDetails) -> Self {
|
||||
Self {
|
||||
model: v.model.into(),
|
||||
overrides: v.overrides.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ListVideoModelsModelDetails {
|
||||
pub model: ListVideoModelsVideoModel,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_creator: Option<ListVideoModelsModelCreator>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub full_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extra_info: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extra_info_short: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_to_video_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_prompt_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_prompt_max_length: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub negative_text_prompt_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub negative_text_prompt_max_length: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub starting_keyframe_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub starting_keyframe_required: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ending_keyframe_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_references_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_references_max: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub video_references_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub video_references_max: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub video_references_max_total_duration_seconds: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audio_references_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audio_references_max: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audio_references_max_total_duration_seconds: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub character_references_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub character_references_max: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub show_generate_with_sound_toggle: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio_options: Option<Vec<ListVideoModelsAspectRatio>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio_default: Option<ListVideoModelsAspectRatio>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution_options: Option<Vec<ListVideoModelsResolution>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution_default: Option<ListVideoModelsResolution>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bitrate_options: Option<Vec<ListVideoModelsBitrate>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bitrate_default: Option<ListVideoModelsBitrate>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality_options: Option<Vec<ListVideoModelsQuality>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_quality: Option<ListVideoModelsQuality>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration_seconds_min: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration_seconds_max: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration_seconds_max_with_image_references: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration_seconds_options: Option<Vec<u16>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration_seconds_default: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_min: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_max: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_options: Option<Vec<u16>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_default: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<OmniGenVideoModelDetails> for ListVideoModelsModelDetails {
|
||||
fn from(v: OmniGenVideoModelDetails) -> Self {
|
||||
Self {
|
||||
model: v.model.into(),
|
||||
model_creator: v.model_creator.map(Into::into),
|
||||
full_name: v.full_name,
|
||||
extra_info: v.extra_info,
|
||||
extra_info_short: v.extra_info_short,
|
||||
text_to_video_supported: v.text_to_video_supported,
|
||||
text_prompt_supported: v.text_prompt_supported,
|
||||
text_prompt_max_length: v.text_prompt_max_length,
|
||||
negative_text_prompt_supported: v.negative_text_prompt_supported,
|
||||
negative_text_prompt_max_length: v.negative_text_prompt_max_length,
|
||||
starting_keyframe_supported: v.starting_keyframe_supported,
|
||||
starting_keyframe_required: v.starting_keyframe_required,
|
||||
ending_keyframe_supported: v.ending_keyframe_supported,
|
||||
image_references_supported: v.image_references_supported,
|
||||
image_references_max: v.image_references_max,
|
||||
video_references_supported: v.video_references_supported,
|
||||
video_references_max: v.video_references_max,
|
||||
video_references_max_total_duration_seconds: v.video_references_max_total_duration_seconds,
|
||||
audio_references_supported: v.audio_references_supported,
|
||||
audio_references_max: v.audio_references_max,
|
||||
audio_references_max_total_duration_seconds: v.audio_references_max_total_duration_seconds,
|
||||
character_references_supported: v.character_references_supported,
|
||||
character_references_max: v.character_references_max,
|
||||
show_generate_with_sound_toggle: v.show_generate_with_sound_toggle,
|
||||
aspect_ratio_options: v.aspect_ratio_options.map(|items| items.into_iter().map(Into::into).collect()),
|
||||
aspect_ratio_default: v.aspect_ratio_default.map(Into::into),
|
||||
resolution_options: v.resolution_options.map(|items| items.into_iter().map(Into::into).collect()),
|
||||
resolution_default: v.resolution_default.map(Into::into),
|
||||
bitrate_options: v.bitrate_options.map(|items| items.into_iter().map(Into::into).collect()),
|
||||
bitrate_default: v.bitrate_default.map(Into::into),
|
||||
quality_options: v.quality_options.map(|items| items.into_iter().map(Into::into).collect()),
|
||||
default_quality: v.default_quality.map(Into::into),
|
||||
duration_seconds_min: v.duration_seconds_min,
|
||||
duration_seconds_max: v.duration_seconds_max,
|
||||
duration_seconds_max_with_image_references: v.duration_seconds_max_with_image_references,
|
||||
duration_seconds_options: v.duration_seconds_options,
|
||||
duration_seconds_default: v.duration_seconds_default,
|
||||
batch_size_min: v.batch_size_min,
|
||||
batch_size_max: v.batch_size_max,
|
||||
batch_size_options: v.batch_size_options,
|
||||
batch_size_default: v.batch_size_default,
|
||||
is_disabled: v.is_disabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,18 +74,10 @@ pub enum GenerationModel {
|
||||
// Generic Midjourney model, version unknown.
|
||||
#[serde(rename = "midjourney")]
|
||||
Midjourney,
|
||||
#[serde(rename = "midjourney_7")]
|
||||
Midjourney7,
|
||||
#[serde(rename = "midjourney_7_niji")]
|
||||
Midjourney7Niji,
|
||||
#[serde(rename = "midjourney_8")]
|
||||
Midjourney8,
|
||||
|
||||
// Generic Grok video model, version unknown.
|
||||
#[serde(rename = "grok_video")]
|
||||
GrokVideo,
|
||||
#[serde(rename = "grok_imagine_video_1p5")]
|
||||
GrokImagineVideo1p5,
|
||||
// TODO: Should be Kling16Pro
|
||||
#[serde(rename = "kling_1.6")]
|
||||
Kling1_6,
|
||||
|
||||
-4
@@ -299,11 +299,7 @@ fn task_model_type_to_generation_model(model: TaskModelType) -> Option<Generatio
|
||||
TaskModelType::QwenEdit2511Angles => Some(GenerationModel::QwenEdit2511Angles),
|
||||
TaskModelType::GrokImage => Some(GenerationModel::GrokImage),
|
||||
TaskModelType::Recraft3 => Some(GenerationModel::Recraft3),
|
||||
TaskModelType::Midjourney7 => Some(GenerationModel::Midjourney7),
|
||||
TaskModelType::Midjourney7Niji => Some(GenerationModel::Midjourney7Niji),
|
||||
TaskModelType::Midjourney8 => Some(GenerationModel::Midjourney8),
|
||||
TaskModelType::GrokVideo => Some(GenerationModel::GrokVideo),
|
||||
TaskModelType::GrokImagineVideo1p5 => Some(GenerationModel::GrokImagineVideo1p5),
|
||||
TaskModelType::Kling16Pro => Some(GenerationModel::Kling1_6),
|
||||
TaskModelType::Kling21Pro => Some(GenerationModel::Kling21Pro),
|
||||
TaskModelType::Kling21Master => Some(GenerationModel::Kling21Master),
|
||||
|
||||
@@ -9,8 +9,6 @@ use crate::core::commands::app_preferences::update_app_preference_command::updat
|
||||
use crate::core::commands::cost_estimate::estimate_image_cost_command::estimate_image_cost_command;
|
||||
use crate::core::commands::cost_estimate::estimate_splat_cost_command::estimate_splat_cost_command;
|
||||
use crate::core::commands::cost_estimate::estimate_video_cost_command::estimate_video_cost_command;
|
||||
use crate::core::commands::generate::models::image::list_image_models_command::list_image_models_command;
|
||||
use crate::core::commands::generate::models::video::list_video_models_command::list_video_models_command;
|
||||
use crate::core::commands::download::download_directory_reveal_command::download_directory_reveal_command;
|
||||
use crate::core::commands::download::download_media_file_command::download_media_file_command;
|
||||
use crate::core::commands::download::download_url_command::download_url_command;
|
||||
@@ -221,8 +219,6 @@ pub fn run() {
|
||||
estimate_image_cost_command,
|
||||
estimate_splat_cost_command,
|
||||
estimate_video_cost_command,
|
||||
list_image_models_command,
|
||||
list_video_models_command,
|
||||
flip_image,
|
||||
generate_image_command,
|
||||
generate_video_command,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
pub const ROOTLY_SERVICE_ID_STORYTELLER_WEB: &str = "9ebebb09-0c56-4c8a-8f57-d5f0f85f3f16";
|
||||
pub const ROOTLY_SERVICE_ID_SEEDANCE2_PRO_JOB: &str = "2a78f884-c42c-4745-b826-b7a572780ea0";
|
||||
pub const ROOTLY_SERVICE_ID_VIDEO_THUMBNAIL_JOB: &str = "9d22997c-4474-47cb-af7b-53119eed1a88";
|
||||
// TODO: create the Rootly service for this job and replace this placeholder id.
|
||||
pub const ROOTLY_SERVICE_ID_USER_SPEND_ANALYTICS_JOB: &str = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
@@ -42,9 +42,7 @@ pub mod uploaded_videos;
|
||||
pub mod user_impersonation_requests;
|
||||
pub mod user_referral_codes;
|
||||
pub mod user_referrals;
|
||||
pub mod user_daily_spends;
|
||||
pub mod user_spend_events;
|
||||
pub mod user_spend_summaries;
|
||||
pub mod users;
|
||||
pub mod vocoder;
|
||||
pub mod voice_clone_requests;
|
||||
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// One (user, namespace) aggregate of spend activity for a single UTC day,
|
||||
/// computed from `user_spend_events`. Only produced for (user, day) pairs that
|
||||
/// actually had spend or refund activity (sparse — see the HAVING clause).
|
||||
pub struct DailySpendAggregate {
|
||||
pub user_token: UserToken,
|
||||
pub payments_namespace: PaymentsNamespace,
|
||||
pub subscription_spend_usd_cents: u64,
|
||||
pub credits_spend_usd_cents: u64,
|
||||
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 AggregateDailySpendsForDateArgs<'c, E>
|
||||
where
|
||||
E: Executor<'c, Database = MySql>,
|
||||
{
|
||||
/// Inclusive start of the UTC day.
|
||||
pub range_start: DateTime<Utc>,
|
||||
/// Exclusive end of the UTC day (start + 1 day).
|
||||
pub range_end: DateTime<Utc>,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Aggregate spend events for one UTC day, grouped by (user, namespace). Rows
|
||||
/// with neither spend nor refund are excluded so `user_daily_spends` stays
|
||||
/// sparse. Only attributed events (non-null user) are counted.
|
||||
pub async fn aggregate_daily_spends_for_date<'c, E>(
|
||||
args: AggregateDailySpendsForDateArgs<'c, E>,
|
||||
) -> Result<Vec<DailySpendAggregate>, sqlx::Error>
|
||||
where
|
||||
E: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let rows = sqlx::query_as!(
|
||||
DailySpendAggregate,
|
||||
r#"
|
||||
SELECT
|
||||
maybe_user_token AS `user_token!: UserToken`,
|
||||
payments_namespace AS `payments_namespace!: PaymentsNamespace`,
|
||||
CAST(SUM(CASE WHEN amount_usd_cents > 0
|
||||
AND event_type IN ('subscription_initial', 'subscription_renewal', 'subscription_proration_upgrade')
|
||||
THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `subscription_spend_usd_cents!: u64`,
|
||||
CAST(SUM(CASE WHEN amount_usd_cents > 0 AND event_type = 'credit_pack_purchase'
|
||||
THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `credits_spend_usd_cents!: u64`,
|
||||
CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`,
|
||||
CAST(SUM(CASE WHEN amount_usd_cents < 0 THEN -amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`,
|
||||
CAST(SUM(amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`,
|
||||
CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`,
|
||||
CAST(COALESCE(SUM(maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64`
|
||||
FROM user_spend_events
|
||||
WHERE maybe_user_token IS NOT NULL
|
||||
AND is_production = TRUE
|
||||
AND payment_occurred_at >= ?
|
||||
AND payment_occurred_at < ?
|
||||
GROUP BY maybe_user_token, payments_namespace
|
||||
HAVING SUM(CASE WHEN amount_usd_cents <> 0 THEN 1 ELSE 0 END) > 0
|
||||
"#,
|
||||
args.range_start,
|
||||
args.range_end,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// One day of a user's spend activity (a `user_daily_spends` row).
|
||||
pub struct UserDailySpendRow {
|
||||
pub payments_namespace: PaymentsNamespace,
|
||||
pub user_token: UserToken,
|
||||
pub spend_date: NaiveDate,
|
||||
pub subscription_spend_usd_cents: u64,
|
||||
pub credits_spend_usd_cents: u64,
|
||||
pub gross_spend_usd_cents: u64,
|
||||
pub refund_usd_cents: u64,
|
||||
pub net_spend_usd_cents: i64,
|
||||
pub payment_count: u32,
|
||||
pub credits_granted: u64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct ListUserDailySpendsArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub user_token: &'e UserToken,
|
||||
pub payments_namespace: PaymentsNamespace,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// A user's daily spend rows, most-recent payment date first.
|
||||
pub async fn list_user_daily_spends<'e, 'c: 'e, E>(
|
||||
args: ListUserDailySpendsArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<UserDailySpendRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let rows = sqlx::query_as!(
|
||||
UserDailySpendRow,
|
||||
r#"
|
||||
SELECT
|
||||
payments_namespace AS `payments_namespace!: PaymentsNamespace`,
|
||||
user_token AS `user_token!: UserToken`,
|
||||
spend_date AS `spend_date!: NaiveDate`,
|
||||
subscription_spend_usd_cents,
|
||||
credits_spend_usd_cents,
|
||||
gross_spend_usd_cents,
|
||||
refund_usd_cents,
|
||||
net_spend_usd_cents,
|
||||
payment_count,
|
||||
credits_granted,
|
||||
created_at AS `created_at: DateTime<Utc>`,
|
||||
updated_at AS `updated_at: DateTime<Utc>`
|
||||
FROM user_daily_spends
|
||||
WHERE user_token = ?
|
||||
AND payments_namespace = ?
|
||||
ORDER BY spend_date DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
args.user_token.as_str(),
|
||||
args.payments_namespace.to_str(),
|
||||
args.limit,
|
||||
args.offset,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod list_user_daily_spends;
|
||||
pub mod aggregate_daily_spends_for_date;
|
||||
pub mod upsert_user_daily_spend;
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// Upsert one `user_daily_spends` row (recompute-overwrite on the
|
||||
/// (user, namespace, date) unique key).
|
||||
pub struct UpsertUserDailySpendArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub user_token: &'e UserToken,
|
||||
pub payments_namespace: PaymentsNamespace,
|
||||
pub spend_date: NaiveDate,
|
||||
pub subscription_spend_usd_cents: u64,
|
||||
pub credits_spend_usd_cents: u64,
|
||||
pub gross_spend_usd_cents: u64,
|
||||
pub refund_usd_cents: u64,
|
||||
pub net_spend_usd_cents: i64,
|
||||
pub payment_count: u32,
|
||||
pub credits_granted: u64,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
pub async fn upsert_user_daily_spend<'e, 'c: 'e, E>(
|
||||
args: UpsertUserDailySpendArgs<'e, 'c, E>,
|
||||
) -> Result<(), sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO user_daily_spends
|
||||
SET
|
||||
user_token = ?,
|
||||
payments_namespace = ?,
|
||||
spend_date = ?,
|
||||
subscription_spend_usd_cents = ?,
|
||||
credits_spend_usd_cents = ?,
|
||||
gross_spend_usd_cents = ?,
|
||||
refund_usd_cents = ?,
|
||||
net_spend_usd_cents = ?,
|
||||
payment_count = ?,
|
||||
credits_granted = ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
subscription_spend_usd_cents = VALUES(subscription_spend_usd_cents),
|
||||
credits_spend_usd_cents = VALUES(credits_spend_usd_cents),
|
||||
gross_spend_usd_cents = VALUES(gross_spend_usd_cents),
|
||||
refund_usd_cents = VALUES(refund_usd_cents),
|
||||
net_spend_usd_cents = VALUES(net_spend_usd_cents),
|
||||
payment_count = VALUES(payment_count),
|
||||
credits_granted = VALUES(credits_granted)
|
||||
"#,
|
||||
args.user_token.as_str(),
|
||||
args.payments_namespace.to_str(),
|
||||
args.spend_date,
|
||||
args.subscription_spend_usd_cents,
|
||||
args.credits_spend_usd_cents,
|
||||
args.gross_spend_usd_cents,
|
||||
args.refund_usd_cents,
|
||||
args.net_spend_usd_cents,
|
||||
args.payment_count,
|
||||
args.credits_granted,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::user_spend_events::UserSpendEventToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// A spend event joined (left) to its user's display info for the moderation list.
|
||||
pub struct UserSpendEventListItem {
|
||||
pub token: UserSpendEventToken,
|
||||
pub payments_namespace: String,
|
||||
pub maybe_user_token: Option<UserToken>,
|
||||
pub maybe_username: Option<String>,
|
||||
pub maybe_display_name: Option<String>,
|
||||
pub maybe_email_gravatar_hash: Option<String>,
|
||||
pub event_type: String,
|
||||
pub amount_usd_cents: i64,
|
||||
pub maybe_credits_granted: Option<u32>,
|
||||
pub payment_source: String,
|
||||
pub maybe_source_object_id: Option<String>,
|
||||
pub maybe_stripe_invoice_id: Option<String>,
|
||||
pub maybe_stripe_payment_intent_id: Option<String>,
|
||||
pub maybe_stripe_charge_id: Option<String>,
|
||||
pub maybe_stripe_customer_id: Option<String>,
|
||||
pub is_production: bool,
|
||||
pub payment_occurred_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct ListUserSpendEventsForModerationArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
/// Optional namespace filter. `None` returns all namespaces.
|
||||
pub maybe_payments_namespace: Option<&'e str>,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// List spend events, most recent payment date first, with the spender's display info.
|
||||
pub async fn list_user_spend_events_for_moderation<'e, 'c: 'e, E>(
|
||||
args: ListUserSpendEventsForModerationArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<UserSpendEventListItem>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let rows = sqlx::query_as!(
|
||||
UserSpendEventListItem,
|
||||
r#"
|
||||
SELECT
|
||||
e.token AS `token!: UserSpendEventToken`,
|
||||
e.payments_namespace AS `payments_namespace!`,
|
||||
e.maybe_user_token AS `maybe_user_token: UserToken`,
|
||||
u.username AS `maybe_username?`,
|
||||
u.display_name AS `maybe_display_name?`,
|
||||
u.email_gravatar_hash AS `maybe_email_gravatar_hash?`,
|
||||
e.event_type AS `event_type!`,
|
||||
e.amount_usd_cents,
|
||||
e.maybe_credits_granted AS `maybe_credits_granted: u32`,
|
||||
e.payment_source AS `payment_source!`,
|
||||
e.source_object_id AS `maybe_source_object_id`,
|
||||
e.maybe_stripe_invoice_id,
|
||||
e.maybe_stripe_payment_intent_id,
|
||||
e.maybe_stripe_charge_id,
|
||||
e.maybe_stripe_customer_id,
|
||||
e.is_production AS `is_production: bool`,
|
||||
e.payment_occurred_at AS `payment_occurred_at!: DateTime<Utc>`,
|
||||
e.created_at AS `created_at!: DateTime<Utc>`
|
||||
FROM user_spend_events e
|
||||
LEFT JOIN users u ON u.token = e.maybe_user_token
|
||||
WHERE (? IS NULL OR e.payments_namespace = ?)
|
||||
ORDER BY e.payment_occurred_at DESC, e.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
args.maybe_payments_namespace,
|
||||
args.maybe_payments_namespace,
|
||||
args.limit,
|
||||
args.offset,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod list_user_spend_events_for_moderation;
|
||||
pub mod backfill_get_user_token_by_email;
|
||||
pub mod backfill_list_artcraft_customer_links;
|
||||
pub mod backfill_list_artcraft_ledger_payments;
|
||||
|
||||
-123
@@ -1,123 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// The full `user_spend_summaries` record for one (namespace, user).
|
||||
pub struct UserSpendSummaryRecord {
|
||||
pub payments_namespace: PaymentsNamespace,
|
||||
pub user_token: UserToken,
|
||||
pub lifetime_gross_spend_usd_cents: u64,
|
||||
pub lifetime_subscription_spend_usd_cents: u64,
|
||||
pub lifetime_credits_spend_usd_cents: u64,
|
||||
pub lifetime_refund_usd_cents: u64,
|
||||
pub lifetime_net_spend_usd_cents: u64,
|
||||
pub lifetime_payment_count: u32,
|
||||
pub lifetime_refund_count: u32,
|
||||
pub maybe_first_payment_at: Option<DateTime<Utc>>,
|
||||
pub first_spend_usd_cents: u64,
|
||||
pub maybe_last_payment_at: Option<DateTime<Utc>>,
|
||||
pub last_spend_usd_cents: u64,
|
||||
pub maybe_days_since_first_payment: Option<u32>,
|
||||
pub maybe_days_since_last_payment: Option<u32>,
|
||||
pub net_spend_7d_usd_cents: i64,
|
||||
pub net_spend_prev_7d_usd_cents: i64,
|
||||
pub net_spend_14d_usd_cents: i64,
|
||||
pub net_spend_prev_14d_usd_cents: i64,
|
||||
pub net_spend_30d_usd_cents: i64,
|
||||
pub net_spend_prev_30d_usd_cents: i64,
|
||||
pub net_spend_60d_usd_cents: i64,
|
||||
pub net_spend_90d_usd_cents: i64,
|
||||
pub net_spend_this_year_usd_cents: i64,
|
||||
pub avg_weekly_net_spend_4w_usd_cents: i64,
|
||||
pub avg_weekly_net_spend_12w_usd_cents: i64,
|
||||
pub active_weeks_in_last_4: u8,
|
||||
pub active_weeks_in_last_8: u8,
|
||||
pub active_weeks_in_last_12: u8,
|
||||
pub active_weeks_in_last_24: u8,
|
||||
pub active_weeks_in_last_52: u8,
|
||||
pub consecutive_active_weeks: u32,
|
||||
pub consecutive_inactive_weeks: u32,
|
||||
pub maybe_weeks_since_last_spend: Option<u32>,
|
||||
pub is_active_subscriber: bool,
|
||||
pub maybe_subscription_interval: Option<String>,
|
||||
pub maybe_reengagement_score: Option<u32>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct GetUserSpendSummaryArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub payments_namespace: PaymentsNamespace,
|
||||
pub user_token: &'e UserToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
pub async fn get_user_spend_summary<'e, 'c: 'e, E>(
|
||||
args: GetUserSpendSummaryArgs<'e, 'c, E>,
|
||||
) -> Result<Option<UserSpendSummaryRecord>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let row = sqlx::query_as!(
|
||||
UserSpendSummaryRecord,
|
||||
r#"
|
||||
SELECT
|
||||
payments_namespace AS `payments_namespace!: PaymentsNamespace`,
|
||||
user_token AS `user_token!: UserToken`,
|
||||
lifetime_gross_spend_usd_cents,
|
||||
lifetime_subscription_spend_usd_cents,
|
||||
lifetime_credits_spend_usd_cents,
|
||||
lifetime_refund_usd_cents,
|
||||
lifetime_net_spend_usd_cents,
|
||||
lifetime_payment_count,
|
||||
lifetime_refund_count,
|
||||
first_payment_at AS `maybe_first_payment_at: DateTime<Utc>`,
|
||||
first_spend_usd_cents,
|
||||
last_payment_at AS `maybe_last_payment_at: DateTime<Utc>`,
|
||||
last_spend_usd_cents,
|
||||
days_since_first_payment AS `maybe_days_since_first_payment: u32`,
|
||||
days_since_last_payment AS `maybe_days_since_last_payment: u32`,
|
||||
net_spend_7d_usd_cents,
|
||||
net_spend_prev_7d_usd_cents,
|
||||
net_spend_14d_usd_cents,
|
||||
net_spend_prev_14d_usd_cents,
|
||||
net_spend_30d_usd_cents,
|
||||
net_spend_prev_30d_usd_cents,
|
||||
net_spend_60d_usd_cents,
|
||||
net_spend_90d_usd_cents,
|
||||
net_spend_this_year_usd_cents,
|
||||
avg_weekly_net_spend_4w_usd_cents,
|
||||
avg_weekly_net_spend_12w_usd_cents,
|
||||
active_weeks_in_last_4,
|
||||
active_weeks_in_last_8,
|
||||
active_weeks_in_last_12,
|
||||
active_weeks_in_last_24,
|
||||
active_weeks_in_last_52,
|
||||
consecutive_active_weeks,
|
||||
consecutive_inactive_weeks,
|
||||
weeks_since_last_spend AS `maybe_weeks_since_last_spend: u32`,
|
||||
is_active_subscriber AS `is_active_subscriber: bool`,
|
||||
maybe_subscription_interval,
|
||||
maybe_reengagement_score AS `maybe_reengagement_score: u32`,
|
||||
created_at AS `created_at: DateTime<Utc>`,
|
||||
updated_at AS `updated_at: DateTime<Utc>`
|
||||
FROM user_spend_summaries
|
||||
WHERE payments_namespace = ?
|
||||
AND user_token = ?
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.payments_namespace.to_str(),
|
||||
args.user_token.as_str(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
|
||||
pub struct ListPaymentsNamespacesWithSpendActivityArgs<'c, E>
|
||||
where
|
||||
E: Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// The distinct payment namespaces that have any attributed spend activity.
|
||||
pub async fn list_payments_namespaces_with_spend_activity<'c, E>(
|
||||
args: ListPaymentsNamespacesWithSpendActivityArgs<'c, E>,
|
||||
) -> Result<Vec<PaymentsNamespace>, sqlx::Error>
|
||||
where
|
||||
E: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let rows = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT DISTINCT payments_namespace AS `payments_namespace!: PaymentsNamespace`
|
||||
FROM user_spend_events
|
||||
WHERE maybe_user_token IS NOT NULL
|
||||
AND is_production = TRUE
|
||||
"#,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// A re-engagement candidate: a lapsed spender (score > 0) joined to display info.
|
||||
pub struct ReengagementCandidate {
|
||||
pub user_token: UserToken,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub email_gravatar_hash: String,
|
||||
pub reengagement_score: u32,
|
||||
pub lifetime_net_spend_usd_cents: u64,
|
||||
pub maybe_last_payment_at: Option<DateTime<Utc>>,
|
||||
pub maybe_days_since_last_payment: Option<u32>,
|
||||
pub maybe_weeks_since_last_spend: Option<u32>,
|
||||
pub is_active_subscriber: bool,
|
||||
}
|
||||
|
||||
pub struct ListReengagementCandidatesArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub payments_namespace: &'e str,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Users that need re-engagement, highest re-engagement score first.
|
||||
pub async fn list_reengagement_candidates<'e, 'c: 'e, E>(
|
||||
args: ListReengagementCandidatesArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<ReengagementCandidate>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let rows = sqlx::query_as!(
|
||||
ReengagementCandidate,
|
||||
r#"
|
||||
SELECT
|
||||
s.user_token AS `user_token!: UserToken`,
|
||||
u.username AS `username!`,
|
||||
u.display_name AS `display_name!`,
|
||||
u.email_gravatar_hash AS `email_gravatar_hash!`,
|
||||
s.maybe_reengagement_score AS `reengagement_score!: u32`,
|
||||
s.lifetime_net_spend_usd_cents,
|
||||
s.last_payment_at AS `maybe_last_payment_at: DateTime<Utc>`,
|
||||
s.days_since_last_payment AS `maybe_days_since_last_payment: u32`,
|
||||
s.weeks_since_last_spend AS `maybe_weeks_since_last_spend: u32`,
|
||||
s.is_active_subscriber AS `is_active_subscriber: bool`
|
||||
FROM user_spend_summaries s
|
||||
JOIN users u ON u.token = s.user_token
|
||||
WHERE s.payments_namespace = ?
|
||||
AND s.maybe_reengagement_score > 0
|
||||
ORDER BY s.maybe_reengagement_score DESC, s.id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
args.payments_namespace,
|
||||
args.limit,
|
||||
args.offset,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// A top spender joined to display info, with all net-spend windows so the
|
||||
/// caller can render the one it sorted by.
|
||||
pub struct TopUserByNetSpend {
|
||||
pub user_token: UserToken,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub email_gravatar_hash: String,
|
||||
pub lifetime_net_spend_usd_cents: u64,
|
||||
pub net_spend_7d_usd_cents: i64,
|
||||
pub net_spend_14d_usd_cents: i64,
|
||||
pub net_spend_30d_usd_cents: i64,
|
||||
pub net_spend_60d_usd_cents: i64,
|
||||
pub net_spend_90d_usd_cents: i64,
|
||||
pub net_spend_this_year_usd_cents: i64,
|
||||
pub is_active_subscriber: bool,
|
||||
pub maybe_reengagement_score: Option<u32>,
|
||||
}
|
||||
|
||||
pub struct ListTopUsersByNetSpendArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub payments_namespace: &'e str,
|
||||
/// One of: 7d, 14d, 30d, 60d, 90d, 1y, lifetime. Selects the sort column.
|
||||
pub window: &'e str,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Top spenders for a namespace, sorted by net spend over the requested window.
|
||||
pub async fn list_top_users_by_net_spend<'e, 'c: 'e, E>(
|
||||
args: ListTopUsersByNetSpendArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<TopUserByNetSpend>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let rows = sqlx::query_as!(
|
||||
TopUserByNetSpend,
|
||||
r#"
|
||||
SELECT
|
||||
s.user_token AS `user_token!: UserToken`,
|
||||
u.username AS `username!`,
|
||||
u.display_name AS `display_name!`,
|
||||
u.email_gravatar_hash AS `email_gravatar_hash!`,
|
||||
s.lifetime_net_spend_usd_cents,
|
||||
s.net_spend_7d_usd_cents,
|
||||
s.net_spend_14d_usd_cents,
|
||||
s.net_spend_30d_usd_cents,
|
||||
s.net_spend_60d_usd_cents,
|
||||
s.net_spend_90d_usd_cents,
|
||||
s.net_spend_this_year_usd_cents,
|
||||
s.is_active_subscriber AS `is_active_subscriber: bool`,
|
||||
s.maybe_reengagement_score AS `maybe_reengagement_score: u32`
|
||||
FROM user_spend_summaries s
|
||||
JOIN users u ON u.token = s.user_token
|
||||
WHERE s.payments_namespace = ?
|
||||
ORDER BY
|
||||
(CASE ?
|
||||
WHEN '7d' THEN s.net_spend_7d_usd_cents
|
||||
WHEN '14d' THEN s.net_spend_14d_usd_cents
|
||||
WHEN '30d' THEN s.net_spend_30d_usd_cents
|
||||
WHEN '60d' THEN s.net_spend_60d_usd_cents
|
||||
WHEN '90d' THEN s.net_spend_90d_usd_cents
|
||||
WHEN '1y' THEN s.net_spend_this_year_usd_cents
|
||||
ELSE CAST(s.lifetime_net_spend_usd_cents AS SIGNED)
|
||||
END) DESC,
|
||||
s.id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
args.payments_namespace,
|
||||
args.window,
|
||||
args.limit,
|
||||
args.offset,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::user_spend_events::payment_event_type::PaymentEventType;
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// A single spend event, trimmed to the fields the summary aggregation needs.
|
||||
pub struct UserSpendEventRow {
|
||||
pub payment_occurred_at: DateTime<Utc>,
|
||||
pub amount_usd_cents: i64,
|
||||
pub event_type: PaymentEventType,
|
||||
pub maybe_credits_granted: Option<u32>,
|
||||
}
|
||||
|
||||
pub struct ListUserSpendEventsForUserArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub user_token: &'e UserToken,
|
||||
pub payments_namespace: PaymentsNamespace,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
pub async fn list_user_spend_events_for_user<'e, 'c: 'e, E>(
|
||||
args: ListUserSpendEventsForUserArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<UserSpendEventRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let rows = sqlx::query_as!(
|
||||
UserSpendEventRow,
|
||||
r#"
|
||||
SELECT
|
||||
payment_occurred_at AS `payment_occurred_at!: DateTime<Utc>`,
|
||||
amount_usd_cents,
|
||||
event_type AS `event_type!: PaymentEventType`,
|
||||
maybe_credits_granted AS `maybe_credits_granted: u32`
|
||||
FROM user_spend_events
|
||||
WHERE maybe_user_token = ?
|
||||
AND payments_namespace = ?
|
||||
AND is_production = TRUE
|
||||
ORDER BY payment_occurred_at ASC
|
||||
"#,
|
||||
args.user_token.as_str(),
|
||||
args.payments_namespace.to_str(),
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// Keyset page of distinct user tokens with spend activity in one namespace.
|
||||
/// Ordered by `user_token`; start with an empty `after_user_token`.
|
||||
pub struct ListUserTokensWithSpendActivityArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub payments_namespace: PaymentsNamespace,
|
||||
pub after_user_token: &'e str,
|
||||
pub limit: i64,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
pub async fn list_user_tokens_with_spend_activity<'e, 'c: 'e, E>(
|
||||
args: ListUserTokensWithSpendActivityArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<UserToken>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let rows = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT DISTINCT maybe_user_token AS `user_token!: UserToken`
|
||||
FROM user_spend_events
|
||||
WHERE maybe_user_token IS NOT NULL
|
||||
AND is_production = TRUE
|
||||
AND payments_namespace = ?
|
||||
AND maybe_user_token > ?
|
||||
ORDER BY maybe_user_token
|
||||
LIMIT ?
|
||||
"#,
|
||||
args.payments_namespace.to_str(),
|
||||
args.after_user_token,
|
||||
args.limit,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
pub mod list_top_users_by_net_spend;
|
||||
pub mod list_reengagement_candidates;
|
||||
pub mod get_user_spend_summary;
|
||||
pub mod list_payments_namespaces_with_spend_activity;
|
||||
pub mod list_user_tokens_with_spend_activity;
|
||||
pub mod list_user_spend_events_for_user;
|
||||
pub mod upsert_user_spend_summary;
|
||||
-192
@@ -1,192 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// Upsert one `user_spend_summaries` row (recompute-overwrite on the
|
||||
/// (payments_namespace, user_token) unique key). Window/cadence/score fields are
|
||||
/// all recomputed each run since they are time-relative.
|
||||
pub struct UpsertUserSpendSummaryArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub payments_namespace: PaymentsNamespace,
|
||||
pub user_token: &'e UserToken,
|
||||
|
||||
// Lifetime
|
||||
pub lifetime_gross_spend_usd_cents: u64,
|
||||
pub lifetime_subscription_spend_usd_cents: u64,
|
||||
pub lifetime_credits_spend_usd_cents: u64,
|
||||
pub lifetime_refund_usd_cents: u64,
|
||||
pub lifetime_net_spend_usd_cents: u64,
|
||||
pub lifetime_payment_count: u32,
|
||||
pub lifetime_refund_count: u32,
|
||||
pub maybe_first_payment_at: Option<DateTime<Utc>>,
|
||||
pub first_spend_usd_cents: u64,
|
||||
pub maybe_last_payment_at: Option<DateTime<Utc>>,
|
||||
pub last_spend_usd_cents: u64,
|
||||
pub maybe_days_since_first_payment: Option<u32>,
|
||||
pub maybe_days_since_last_payment: Option<u32>,
|
||||
|
||||
// Sliding NET windows (signed)
|
||||
pub net_spend_7d_usd_cents: i64,
|
||||
pub net_spend_prev_7d_usd_cents: i64,
|
||||
pub net_spend_14d_usd_cents: i64,
|
||||
pub net_spend_prev_14d_usd_cents: i64,
|
||||
pub net_spend_30d_usd_cents: i64,
|
||||
pub net_spend_prev_30d_usd_cents: i64,
|
||||
pub net_spend_60d_usd_cents: i64,
|
||||
pub net_spend_90d_usd_cents: i64,
|
||||
pub net_spend_this_year_usd_cents: i64,
|
||||
|
||||
// Weekly cadence / rhythm
|
||||
pub avg_weekly_net_spend_4w_usd_cents: i64,
|
||||
pub avg_weekly_net_spend_12w_usd_cents: i64,
|
||||
pub active_weeks_in_last_4: u8,
|
||||
pub active_weeks_in_last_8: u8,
|
||||
pub active_weeks_in_last_12: u8,
|
||||
pub active_weeks_in_last_24: u8,
|
||||
pub active_weeks_in_last_52: u8,
|
||||
pub consecutive_active_weeks: u32,
|
||||
pub consecutive_inactive_weeks: u32,
|
||||
pub maybe_weeks_since_last_spend: Option<u32>,
|
||||
|
||||
// Flags
|
||||
pub is_active_subscriber: bool,
|
||||
pub maybe_subscription_interval: Option<&'e str>,
|
||||
pub maybe_reengagement_score: Option<u32>,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
pub async fn upsert_user_spend_summary<'e, 'c: 'e, E>(
|
||||
args: UpsertUserSpendSummaryArgs<'e, 'c, E>,
|
||||
) -> Result<(), sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO user_spend_summaries
|
||||
SET
|
||||
payments_namespace = ?,
|
||||
user_token = ?,
|
||||
lifetime_gross_spend_usd_cents = ?,
|
||||
lifetime_subscription_spend_usd_cents = ?,
|
||||
lifetime_credits_spend_usd_cents = ?,
|
||||
lifetime_refund_usd_cents = ?,
|
||||
lifetime_net_spend_usd_cents = ?,
|
||||
lifetime_payment_count = ?,
|
||||
lifetime_refund_count = ?,
|
||||
first_payment_at = ?,
|
||||
first_spend_usd_cents = ?,
|
||||
last_payment_at = ?,
|
||||
last_spend_usd_cents = ?,
|
||||
days_since_first_payment = ?,
|
||||
days_since_last_payment = ?,
|
||||
net_spend_7d_usd_cents = ?,
|
||||
net_spend_prev_7d_usd_cents = ?,
|
||||
net_spend_14d_usd_cents = ?,
|
||||
net_spend_prev_14d_usd_cents = ?,
|
||||
net_spend_30d_usd_cents = ?,
|
||||
net_spend_prev_30d_usd_cents = ?,
|
||||
net_spend_60d_usd_cents = ?,
|
||||
net_spend_90d_usd_cents = ?,
|
||||
net_spend_this_year_usd_cents = ?,
|
||||
avg_weekly_net_spend_4w_usd_cents = ?,
|
||||
avg_weekly_net_spend_12w_usd_cents = ?,
|
||||
active_weeks_in_last_4 = ?,
|
||||
active_weeks_in_last_8 = ?,
|
||||
active_weeks_in_last_12 = ?,
|
||||
active_weeks_in_last_24 = ?,
|
||||
active_weeks_in_last_52 = ?,
|
||||
consecutive_active_weeks = ?,
|
||||
consecutive_inactive_weeks = ?,
|
||||
weeks_since_last_spend = ?,
|
||||
is_active_subscriber = ?,
|
||||
maybe_subscription_interval = ?,
|
||||
maybe_reengagement_score = ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
lifetime_gross_spend_usd_cents = VALUES(lifetime_gross_spend_usd_cents),
|
||||
lifetime_subscription_spend_usd_cents = VALUES(lifetime_subscription_spend_usd_cents),
|
||||
lifetime_credits_spend_usd_cents = VALUES(lifetime_credits_spend_usd_cents),
|
||||
lifetime_refund_usd_cents = VALUES(lifetime_refund_usd_cents),
|
||||
lifetime_net_spend_usd_cents = VALUES(lifetime_net_spend_usd_cents),
|
||||
lifetime_payment_count = VALUES(lifetime_payment_count),
|
||||
lifetime_refund_count = VALUES(lifetime_refund_count),
|
||||
first_payment_at = VALUES(first_payment_at),
|
||||
first_spend_usd_cents = VALUES(first_spend_usd_cents),
|
||||
last_payment_at = VALUES(last_payment_at),
|
||||
last_spend_usd_cents = VALUES(last_spend_usd_cents),
|
||||
days_since_first_payment = VALUES(days_since_first_payment),
|
||||
days_since_last_payment = VALUES(days_since_last_payment),
|
||||
net_spend_7d_usd_cents = VALUES(net_spend_7d_usd_cents),
|
||||
net_spend_prev_7d_usd_cents = VALUES(net_spend_prev_7d_usd_cents),
|
||||
net_spend_14d_usd_cents = VALUES(net_spend_14d_usd_cents),
|
||||
net_spend_prev_14d_usd_cents = VALUES(net_spend_prev_14d_usd_cents),
|
||||
net_spend_30d_usd_cents = VALUES(net_spend_30d_usd_cents),
|
||||
net_spend_prev_30d_usd_cents = VALUES(net_spend_prev_30d_usd_cents),
|
||||
net_spend_60d_usd_cents = VALUES(net_spend_60d_usd_cents),
|
||||
net_spend_90d_usd_cents = VALUES(net_spend_90d_usd_cents),
|
||||
net_spend_this_year_usd_cents = VALUES(net_spend_this_year_usd_cents),
|
||||
avg_weekly_net_spend_4w_usd_cents = VALUES(avg_weekly_net_spend_4w_usd_cents),
|
||||
avg_weekly_net_spend_12w_usd_cents = VALUES(avg_weekly_net_spend_12w_usd_cents),
|
||||
active_weeks_in_last_4 = VALUES(active_weeks_in_last_4),
|
||||
active_weeks_in_last_8 = VALUES(active_weeks_in_last_8),
|
||||
active_weeks_in_last_12 = VALUES(active_weeks_in_last_12),
|
||||
active_weeks_in_last_24 = VALUES(active_weeks_in_last_24),
|
||||
active_weeks_in_last_52 = VALUES(active_weeks_in_last_52),
|
||||
consecutive_active_weeks = VALUES(consecutive_active_weeks),
|
||||
consecutive_inactive_weeks = VALUES(consecutive_inactive_weeks),
|
||||
weeks_since_last_spend = VALUES(weeks_since_last_spend),
|
||||
is_active_subscriber = VALUES(is_active_subscriber),
|
||||
maybe_subscription_interval = VALUES(maybe_subscription_interval),
|
||||
maybe_reengagement_score = VALUES(maybe_reengagement_score)
|
||||
"#,
|
||||
args.payments_namespace.to_str(),
|
||||
args.user_token.as_str(),
|
||||
args.lifetime_gross_spend_usd_cents,
|
||||
args.lifetime_subscription_spend_usd_cents,
|
||||
args.lifetime_credits_spend_usd_cents,
|
||||
args.lifetime_refund_usd_cents,
|
||||
args.lifetime_net_spend_usd_cents,
|
||||
args.lifetime_payment_count,
|
||||
args.lifetime_refund_count,
|
||||
args.maybe_first_payment_at,
|
||||
args.first_spend_usd_cents,
|
||||
args.maybe_last_payment_at,
|
||||
args.last_spend_usd_cents,
|
||||
args.maybe_days_since_first_payment,
|
||||
args.maybe_days_since_last_payment,
|
||||
args.net_spend_7d_usd_cents,
|
||||
args.net_spend_prev_7d_usd_cents,
|
||||
args.net_spend_14d_usd_cents,
|
||||
args.net_spend_prev_14d_usd_cents,
|
||||
args.net_spend_30d_usd_cents,
|
||||
args.net_spend_prev_30d_usd_cents,
|
||||
args.net_spend_60d_usd_cents,
|
||||
args.net_spend_90d_usd_cents,
|
||||
args.net_spend_this_year_usd_cents,
|
||||
args.avg_weekly_net_spend_4w_usd_cents,
|
||||
args.avg_weekly_net_spend_12w_usd_cents,
|
||||
args.active_weeks_in_last_4,
|
||||
args.active_weeks_in_last_8,
|
||||
args.active_weeks_in_last_12,
|
||||
args.active_weeks_in_last_24,
|
||||
args.active_weeks_in_last_52,
|
||||
args.consecutive_active_weeks,
|
||||
args.consecutive_inactive_weeks,
|
||||
args.maybe_weeks_since_last_spend,
|
||||
args.is_active_subscriber,
|
||||
args.maybe_subscription_interval,
|
||||
args.maybe_reengagement_score,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// The user's most-relevant subscription (latest-expiring), for deriving
|
||||
/// `is_active_subscriber` and `maybe_subscription_interval` in the analytics job.
|
||||
pub struct UserSubscriptionStatusRow {
|
||||
pub maybe_stripe_subscription_status: Option<String>,
|
||||
pub maybe_stripe_recurring_interval: Option<String>,
|
||||
pub subscription_expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct GetActiveSubscriptionStatusArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub user_token: &'e UserToken,
|
||||
pub subscription_namespace: PaymentsNamespace,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Returns the latest-expiring subscription row for the user (or `None`). The
|
||||
/// caller decides active-ness from the status + expiry.
|
||||
pub async fn get_active_subscription_status_for_user<'e, 'c: 'e, E>(
|
||||
args: GetActiveSubscriptionStatusArgs<'e, 'c, E>,
|
||||
) -> Result<Option<UserSubscriptionStatusRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let row = sqlx::query_as!(
|
||||
UserSubscriptionStatusRow,
|
||||
r#"
|
||||
SELECT
|
||||
maybe_stripe_subscription_status,
|
||||
maybe_stripe_recurring_interval,
|
||||
subscription_expires_at AS `subscription_expires_at!: DateTime<Utc>`
|
||||
FROM user_subscriptions
|
||||
WHERE user_token = ?
|
||||
AND subscription_namespace = ?
|
||||
ORDER BY subscription_expires_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.user_token.as_str(),
|
||||
args.subscription_namespace.to_str(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod get_active_subscription_status_for_user;
|
||||
pub mod find_possibly_inactive_first_subscription_for_owner_user;
|
||||
pub mod find_subscription_for_owner_user;
|
||||
pub mod get_user_subscription_by_stripe_subscription_id;
|
||||
|
||||
@@ -59,18 +59,10 @@ pub enum TaskModelType {
|
||||
// Generic Midjourney model, version unknown.
|
||||
#[serde(rename = "midjourney")]
|
||||
Midjourney,
|
||||
#[serde(rename = "midjourney_7")]
|
||||
Midjourney7,
|
||||
#[serde(rename = "midjourney_7_niji")]
|
||||
Midjourney7Niji,
|
||||
#[serde(rename = "midjourney_8")]
|
||||
Midjourney8,
|
||||
|
||||
// Video models
|
||||
#[serde(rename = "grok_video")]
|
||||
GrokVideo, // Video version unspecified/unknown
|
||||
#[serde(rename = "grok_imagine_video_1p5")]
|
||||
GrokImagineVideo1p5,
|
||||
#[serde(rename = "kling_1.6_pro")]
|
||||
Kling16Pro,
|
||||
#[serde(rename = "kling_2.1_pro")]
|
||||
@@ -157,12 +149,8 @@ impl TaskModelType {
|
||||
Self::GrokImage => "grok_image",
|
||||
Self::Recraft3 => "recraft_3",
|
||||
Self::Midjourney => "midjourney",
|
||||
Self::Midjourney7 => "midjourney_7",
|
||||
Self::Midjourney7Niji => "midjourney_7_niji",
|
||||
Self::Midjourney8 => "midjourney_8",
|
||||
// Video models
|
||||
Self::GrokVideo => "grok_video",
|
||||
Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5",
|
||||
Self::Kling16Pro => "kling_1.6_pro",
|
||||
Self::Kling21Pro => "kling_2.1_pro",
|
||||
Self::Kling21Master => "kling_2.1_master",
|
||||
@@ -217,12 +205,8 @@ impl TaskModelType {
|
||||
"grok_image" => Ok(Self::GrokImage),
|
||||
"recraft_3" => Ok(Self::Recraft3),
|
||||
"midjourney" => Ok(Self::Midjourney),
|
||||
"midjourney_7" => Ok(Self::Midjourney7),
|
||||
"midjourney_7_niji" => Ok(Self::Midjourney7Niji),
|
||||
"midjourney_8" => Ok(Self::Midjourney8),
|
||||
// Video models
|
||||
"grok_video" => Ok(Self::GrokVideo),
|
||||
"grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5),
|
||||
"kling_1.6_pro" => Ok(Self::Kling16Pro),
|
||||
"kling_2.1_pro" => Ok(Self::Kling21Pro),
|
||||
"kling_2.1_master" => Ok(Self::Kling21Master),
|
||||
@@ -280,12 +264,8 @@ impl TaskModelType {
|
||||
Self::GrokImage,
|
||||
Self::Recraft3,
|
||||
Self::Midjourney,
|
||||
Self::Midjourney7,
|
||||
Self::Midjourney7Niji,
|
||||
Self::Midjourney8,
|
||||
// Video models
|
||||
Self::GrokVideo,
|
||||
Self::GrokImagineVideo1p5,
|
||||
Self::Kling16Pro,
|
||||
Self::Kling21Pro,
|
||||
Self::Kling21Master,
|
||||
@@ -351,11 +331,7 @@ mod tests {
|
||||
assert_serialization(TaskModelType::Recraft3, "recraft_3");
|
||||
assert_serialization(TaskModelType::Midjourney, "midjourney");
|
||||
// Video models
|
||||
assert_serialization(TaskModelType::Midjourney7, "midjourney_7");
|
||||
assert_serialization(TaskModelType::Midjourney7Niji, "midjourney_7_niji");
|
||||
assert_serialization(TaskModelType::Midjourney8, "midjourney_8");
|
||||
assert_serialization(TaskModelType::GrokVideo, "grok_video");
|
||||
assert_serialization(TaskModelType::GrokImagineVideo1p5, "grok_imagine_video_1p5");
|
||||
assert_serialization(TaskModelType::Kling16Pro, "kling_1.6_pro");
|
||||
assert_serialization(TaskModelType::Kling21Pro, "kling_2.1_pro");
|
||||
assert_serialization(TaskModelType::Kling21Master, "kling_2.1_master");
|
||||
@@ -411,7 +387,6 @@ mod tests {
|
||||
assert_eq!(TaskModelType::Midjourney.to_str(), "midjourney");
|
||||
// Video models
|
||||
assert_eq!(TaskModelType::GrokVideo.to_str(), "grok_video");
|
||||
assert_eq!(TaskModelType::GrokImagineVideo1p5.to_str(), "grok_imagine_video_1p5");
|
||||
assert_eq!(TaskModelType::Kling16Pro.to_str(), "kling_1.6_pro");
|
||||
assert_eq!(TaskModelType::Kling21Pro.to_str(), "kling_2.1_pro");
|
||||
assert_eq!(TaskModelType::Kling21Master.to_str(), "kling_2.1_master");
|
||||
@@ -467,7 +442,6 @@ mod tests {
|
||||
assert_eq!(TaskModelType::from_str("midjourney").unwrap(), TaskModelType::Midjourney);
|
||||
// Video models
|
||||
assert_eq!(TaskModelType::from_str("grok_video").unwrap(), TaskModelType::GrokVideo);
|
||||
assert_eq!(TaskModelType::from_str("grok_imagine_video_1p5").unwrap(), TaskModelType::GrokImagineVideo1p5);
|
||||
assert_eq!(TaskModelType::from_str("kling_1.6_pro").unwrap(), TaskModelType::Kling16Pro);
|
||||
assert_eq!(TaskModelType::from_str("kling_2.1_pro").unwrap(), TaskModelType::Kling21Pro);
|
||||
assert_eq!(TaskModelType::from_str("kling_2.1_master").unwrap(), TaskModelType::Kling21Master);
|
||||
@@ -510,7 +484,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = TaskModelType::all_variants();
|
||||
assert_eq!(variants.len(), 52);
|
||||
assert_eq!(variants.len(), 48);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
|
||||
@@ -534,12 +508,8 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::GrokImage));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Recraft3));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney7));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney7Niji));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney8));
|
||||
// Video models
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::GrokVideo));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::GrokImagineVideo1p5));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling16Pro));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Pro));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Master));
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
[package]
|
||||
name = "user-spend-analytics-job"
|
||||
edition = "2021"
|
||||
version = "0.0.1"
|
||||
authors = [
|
||||
"Brandon Thomas <bt@brand.io>",
|
||||
"Brandon Thomas <echelon@gmail.com>",
|
||||
]
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "user-spend-analytics-job"
|
||||
path = "src/main.rs"
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[dependencies]
|
||||
# cargo-hakari workspace hack (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# Internal
|
||||
actix_helpers.workspace = true
|
||||
bootstrap.workspace = true
|
||||
concurrency.workspace = true
|
||||
easyenv.workspace = true
|
||||
enums.workspace = true
|
||||
errors.workspace = true
|
||||
jobs_common.workspace = true
|
||||
mysql_queries.workspace = true
|
||||
pager = { path = "../../../lib/pager" }
|
||||
rootly_client.workspace = true
|
||||
rootly_config.workspace = true
|
||||
shared_env_var_config.workspace = true
|
||||
server_environment = { path = "../../../lib/server_environment" }
|
||||
tokens.workspace = true
|
||||
|
||||
# External
|
||||
actix-web.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
serde_json.workspace = true
|
||||
sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono" ] }
|
||||
tokio.workspace = true
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
SERVER_ENVIRONMENT=development
|
||||
RUST_LOG=info
|
||||
|
||||
# Cycle cadence + throttling
|
||||
SLEEP_BETWEEN_CYCLES_SECONDS=86400
|
||||
QUERY_DELAY_MILLIS=50
|
||||
ERROR_RECOVERY_SECONDS=30
|
||||
|
||||
# Daily-spends backfill window (pick ONE; omit both = all-time, with a warning)
|
||||
BACKFILL_DAYS=3
|
||||
# BACKFILL_START_DATE=2026-02-01
|
||||
|
||||
# Summaries
|
||||
SUMMARY_USER_PAGE_SIZE=500
|
||||
|
||||
# Reengagement score tunables
|
||||
REENGAGEMENT_VALUE_CAP_DOLLARS=1000
|
||||
REENGAGEMENT_ACTIVE_THRESHOLD_DAYS=14
|
||||
REENGAGEMENT_DECAY_DAYS=350
|
||||
|
||||
# Paging (off in dev)
|
||||
ENABLE_PAGING=false
|
||||
-127
@@ -1,127 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::header::ContentType;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use log::error;
|
||||
|
||||
use actix_helpers::response_serializers::error_to_json_http_response::error_to_json_http_response;
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
|
||||
use crate::http_server::http_server_shared_state::HttpServerSharedState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HealthCheckResponse {
|
||||
pub success: bool,
|
||||
pub is_healthy: bool,
|
||||
pub consecutive_failure_count: u64,
|
||||
pub consecutive_success_count: u64,
|
||||
pub total_failure_count: u64,
|
||||
pub total_success_count: u64,
|
||||
pub total_failure_ratio: f32,
|
||||
pub total_success_ratio: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub enum HealthCheckError {
|
||||
ServerError,
|
||||
}
|
||||
|
||||
impl ResponseError for HealthCheckError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match *self {
|
||||
HealthCheckError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
error_to_json_http_response(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HealthCheckError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_health_check_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<HttpServerSharedState>>,
|
||||
) -> Result<HttpResponse, HealthCheckError> {
|
||||
let job_stats = server_state
|
||||
.job_stats
|
||||
.get_status()
|
||||
.map_err(|e| {
|
||||
error!("Error serving health check status: {:?}", e);
|
||||
HealthCheckError::ServerError
|
||||
})?;
|
||||
|
||||
let total_tries = job_stats
|
||||
.total_failure_count
|
||||
.saturating_add(job_stats.total_success_count);
|
||||
|
||||
let total_success_ratio = if total_tries > 0 {
|
||||
(job_stats.total_success_count as f32) / (total_tries as f32)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let total_failure_ratio = if total_tries > 0 {
|
||||
1.0 - total_success_ratio
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let is_healthy =
|
||||
job_stats.consecutive_failure_count < server_state.consecutive_failure_unhealthy_threshold;
|
||||
|
||||
if !is_healthy {
|
||||
let notification = NotificationDetailsBuilder::from_title(
|
||||
format!("Health check unhealthy on {}", server_state.hostname))
|
||||
.set_description(Some(format!(
|
||||
"Health check returned unhealthy.\n\n\
|
||||
Hostname: {}\n\
|
||||
Consecutive failure count: {}\n\
|
||||
Total failure count: {}\n\
|
||||
Total success count: {}",
|
||||
server_state.hostname,
|
||||
job_stats.consecutive_failure_count,
|
||||
job_stats.total_failure_count,
|
||||
job_stats.total_success_count,
|
||||
)))
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.set_http_method(Some(http_request.method().to_string()))
|
||||
.set_http_path(Some(http_request.path().to_string()))
|
||||
.build();
|
||||
|
||||
if let Err(err) = server_state.pager.enqueue_page(notification) {
|
||||
error!("Failed to enqueue health check alert: {:?}", err);
|
||||
}
|
||||
}
|
||||
|
||||
let response = HealthCheckResponse {
|
||||
success: true,
|
||||
is_healthy,
|
||||
consecutive_failure_count: job_stats.consecutive_failure_count,
|
||||
consecutive_success_count: job_stats.consecutive_success_count,
|
||||
total_failure_count: job_stats.total_failure_count,
|
||||
total_success_count: job_stats.total_success_count,
|
||||
total_failure_ratio,
|
||||
total_success_ratio,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response).map_err(|_e| HealthCheckError::ServerError)?;
|
||||
|
||||
if is_healthy {
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type(ContentType::json())
|
||||
.body(body))
|
||||
} else {
|
||||
Ok(HttpResponse::InternalServerError()
|
||||
.content_type(ContentType::json())
|
||||
.body(body))
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod health_check_handler;
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
use jobs_common::job_stats::JobStats;
|
||||
use pager::client::pager::Pager;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HttpServerSharedState {
|
||||
pub job_stats: JobStats,
|
||||
pub consecutive_failure_unhealthy_threshold: u64,
|
||||
pub pager: Pager,
|
||||
pub hostname: String,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod endpoints;
|
||||
pub mod http_server_shared_state;
|
||||
pub mod run_http_server;
|
||||
@@ -1,76 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::dev::Server;
|
||||
use actix_web::middleware::Logger;
|
||||
use actix_web::{web, App, HttpResponse, HttpServer};
|
||||
use log::info;
|
||||
|
||||
use bootstrap::bootstrap::ContainerEnvironment;
|
||||
use errors::AnyhowResult;
|
||||
use jobs_common::job_stats::JobStats;
|
||||
use pager::client::pager::Pager;
|
||||
|
||||
use crate::http_server::endpoints::health_check_handler::get_health_check_handler;
|
||||
use crate::http_server::http_server_shared_state::HttpServerSharedState;
|
||||
|
||||
const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:13339";
|
||||
const DEFAULT_NUM_WORKERS: usize = 2;
|
||||
|
||||
pub struct CreateServerArgs {
|
||||
pub container_environment: ContainerEnvironment,
|
||||
pub job_stats: JobStats,
|
||||
pub pager: Pager,
|
||||
}
|
||||
|
||||
pub fn run_http_server(args: CreateServerArgs) -> AnyhowResult<Server> {
|
||||
let bind_address = easyenv::get_env_string_or_default("HTTP_BIND_ADDRESS", DEFAULT_BIND_ADDRESS);
|
||||
let num_workers = easyenv::get_env_num("HTTP_NUM_WORKERS", DEFAULT_NUM_WORKERS)?;
|
||||
let hostname = args.container_environment.hostname.clone();
|
||||
|
||||
let server_state = HttpServerSharedState {
|
||||
job_stats: args.job_stats.clone(),
|
||||
consecutive_failure_unhealthy_threshold: easyenv::get_env_num(
|
||||
"CONSECUTIVE_FAILURE_UNHEALTHY_THRESHOLD",
|
||||
3,
|
||||
)?,
|
||||
pager: args.pager,
|
||||
hostname,
|
||||
};
|
||||
|
||||
let server_state_arc = web::Data::new(Arc::new(server_state));
|
||||
|
||||
info!("Starting HTTP service (for k8s health checking).");
|
||||
|
||||
let log_format = "[%{HOSTNAME}e] IP=[%{X-Forwarded-For}i] \"%r\" %s %b \"%{Referer}i\" \"%{User-Agent}i\" %T";
|
||||
|
||||
let handle = HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(server_state_arc.clone())
|
||||
.wrap(Logger::new(&log_format).exclude("/_status"))
|
||||
.service(
|
||||
web::resource("/")
|
||||
.route(web::get().to(|| HttpResponse::Ok()))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/_status")
|
||||
.route(web::get().to(get_health_check_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
})
|
||||
.bind(&bind_address)
|
||||
.unwrap_or_else(|err| {
|
||||
eprintln!("FATAL: Failed to bind to address '{}': {}", bind_address, err);
|
||||
eprintln!("The address is likely already in use by another process.");
|
||||
std::process::exit(1);
|
||||
})
|
||||
.workers(num_workers)
|
||||
.run();
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
pub async fn launch_http_server(args: CreateServerArgs) -> AnyhowResult<()> {
|
||||
run_http_server(args)?.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
use log::error;
|
||||
use pager::client::pager::Pager;
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
|
||||
/// Enqueue a pager alert for an error WITHOUT interrupting the job. The caller is
|
||||
/// expected to keep going (next date / user / record) after a recovery sleep.
|
||||
pub fn alert_pager(pager: &Pager, title: &str, detail: &str) {
|
||||
let notification = NotificationDetailsBuilder::from_title(title.to_string())
|
||||
.set_description(Some(detail.to_string()))
|
||||
.set_urgency(Some(NotificationUrgency::Medium))
|
||||
.build();
|
||||
|
||||
if let Err(pager_err) = pager.enqueue_page(notification) {
|
||||
error!("Failed to enqueue pager alert: {:?}", pager_err);
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, TimeZone, Utc};
|
||||
use log::{error, info, warn};
|
||||
|
||||
use mysql_queries::queries::user_daily_spends::aggregate_daily_spends_for_date::{
|
||||
aggregate_daily_spends_for_date, AggregateDailySpendsForDateArgs,
|
||||
};
|
||||
use mysql_queries::queries::user_daily_spends::upsert_user_daily_spend::{
|
||||
upsert_user_daily_spend, UpsertUserDailySpendArgs,
|
||||
};
|
||||
|
||||
use crate::job::alert_on_error::alert_pager;
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
|
||||
const ALL_TIME_START: (i32, u32, u32) = (2020, 1, 1);
|
||||
|
||||
/// Backfill `user_daily_spends` for each UTC day from the configured start date
|
||||
/// up to today, one date at a time. Days with no spend/refund produce no rows.
|
||||
pub async fn backfill_daily_spends(deps: &JobDependencies) {
|
||||
let start_date = resolve_start_date(deps);
|
||||
let today = Utc::now().date_naive();
|
||||
info!("Daily-spends backfill window: {start_date} .. {today} (UTC).");
|
||||
|
||||
let mut date = start_date;
|
||||
let mut total_upserted = 0u64;
|
||||
|
||||
while date <= today {
|
||||
if deps.application_shutdown.get() {
|
||||
return;
|
||||
}
|
||||
match process_date(deps, date).await {
|
||||
Ok(count) => {
|
||||
total_upserted += count as u64;
|
||||
if count > 0 {
|
||||
info!(" {date}: upserted {count} daily row(s).");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Daily-spends aggregation failed for {date}: {err:?}");
|
||||
alert_pager(
|
||||
&deps.pager,
|
||||
"user-spend-analytics: daily aggregation error",
|
||||
&format!("date={date}: {err:?}"),
|
||||
);
|
||||
let _ = deps.job_stats.increment_failure_count();
|
||||
tokio::time::sleep(deps.error_recovery).await;
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(deps.query_delay).await;
|
||||
date = match date.succ_opt() {
|
||||
Some(next) => next,
|
||||
None => break,
|
||||
};
|
||||
}
|
||||
|
||||
info!("Daily-spends backfill complete: {total_upserted} row(s) upserted.");
|
||||
}
|
||||
|
||||
async fn process_date(deps: &JobDependencies, date: NaiveDate) -> Result<usize, sqlx::Error> {
|
||||
let range_start: DateTime<Utc> =
|
||||
Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).expect("valid midnight"));
|
||||
let range_end = range_start + ChronoDuration::days(1);
|
||||
|
||||
let aggregates = aggregate_daily_spends_for_date(AggregateDailySpendsForDateArgs {
|
||||
range_start,
|
||||
range_end,
|
||||
mysql_executor: &deps.mysql_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut upserted = 0usize;
|
||||
for aggregate in &aggregates {
|
||||
if deps.application_shutdown.get() {
|
||||
break;
|
||||
}
|
||||
let result = upsert_user_daily_spend(UpsertUserDailySpendArgs {
|
||||
user_token: &aggregate.user_token,
|
||||
payments_namespace: aggregate.payments_namespace,
|
||||
spend_date: date,
|
||||
subscription_spend_usd_cents: aggregate.subscription_spend_usd_cents,
|
||||
credits_spend_usd_cents: aggregate.credits_spend_usd_cents,
|
||||
gross_spend_usd_cents: aggregate.gross_spend_usd_cents,
|
||||
refund_usd_cents: aggregate.refund_usd_cents,
|
||||
net_spend_usd_cents: aggregate.net_spend_usd_cents,
|
||||
payment_count: u32::try_from(aggregate.payment_count).unwrap_or(u32::MAX),
|
||||
credits_granted: aggregate.credits_granted,
|
||||
mysql_executor: &deps.mysql_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
upserted += 1;
|
||||
let _ = deps.job_stats.increment_success_count();
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Daily upsert failed for user {} on {date}: {err:?}", aggregate.user_token.as_str());
|
||||
alert_pager(
|
||||
&deps.pager,
|
||||
"user-spend-analytics: daily upsert error",
|
||||
&format!("user={} date={date}: {err:?}", aggregate.user_token.as_str()),
|
||||
);
|
||||
let _ = deps.job_stats.increment_failure_count();
|
||||
tokio::time::sleep(deps.error_recovery).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(upserted)
|
||||
}
|
||||
|
||||
/// `BACKFILL_START_DATE` wins; else `today - BACKFILL_DAYS`; else all-time (with a loud warning).
|
||||
fn resolve_start_date(deps: &JobDependencies) -> NaiveDate {
|
||||
if let Some(start_date) = deps.maybe_backfill_start_date {
|
||||
return start_date;
|
||||
}
|
||||
let today = Utc::now().date_naive();
|
||||
if let Some(days) = deps.maybe_backfill_days {
|
||||
return today - ChronoDuration::days(days.max(0));
|
||||
}
|
||||
warn!(
|
||||
"⚠️ Neither BACKFILL_START_DATE nor BACKFILL_DAYS is set — backfilling \
|
||||
user_daily_spends for ALL TIME. This is expensive; set one to bound the window."
|
||||
);
|
||||
NaiveDate::from_ymd_opt(ALL_TIME_START.0, ALL_TIME_START.1, ALL_TIME_START.2).expect("valid date")
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Datelike, Duration, Utc};
|
||||
use log::{error, info};
|
||||
|
||||
use enums::by_table::user_spend_events::payment_event_type::PaymentEventType;
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use mysql_queries::queries::user_spend_summaries::list_payments_namespaces_with_spend_activity::{
|
||||
list_payments_namespaces_with_spend_activity, ListPaymentsNamespacesWithSpendActivityArgs,
|
||||
};
|
||||
use mysql_queries::queries::user_spend_summaries::list_user_spend_events_for_user::{
|
||||
list_user_spend_events_for_user, ListUserSpendEventsForUserArgs, UserSpendEventRow,
|
||||
};
|
||||
use mysql_queries::queries::user_spend_summaries::list_user_tokens_with_spend_activity::{
|
||||
list_user_tokens_with_spend_activity, ListUserTokensWithSpendActivityArgs,
|
||||
};
|
||||
use mysql_queries::queries::user_spend_summaries::upsert_user_spend_summary::{
|
||||
upsert_user_spend_summary, UpsertUserSpendSummaryArgs,
|
||||
};
|
||||
use mysql_queries::queries::users::user_subscriptions::get_active_subscription_status_for_user::{
|
||||
get_active_subscription_status_for_user, GetActiveSubscriptionStatusArgs,
|
||||
};
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::job::alert_on_error::alert_pager;
|
||||
use crate::job::reengagement_score::reengagement_score;
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
|
||||
const DAYS_PER_WEEK: i64 = 7;
|
||||
/// Emit a progress line every N users during the (long, otherwise silent) summaries pass.
|
||||
const SUMMARY_LOG_EVERY: u64 = 200;
|
||||
|
||||
/// Recompute `user_spend_summaries` for every (user, namespace) with spend
|
||||
/// activity. Window/cadence/score fields are time-relative, so all users are
|
||||
/// refreshed each cycle against a single reference time. We iterate one namespace
|
||||
/// at a time, keyset-paginating users by `user_token`.
|
||||
pub async fn backfill_user_summaries(deps: &JobDependencies) {
|
||||
let now = Utc::now();
|
||||
info!("User-summaries backfill starting (reference time {now}).");
|
||||
|
||||
let namespaces = match list_payments_namespaces_with_spend_activity(
|
||||
ListPaymentsNamespacesWithSpendActivityArgs {
|
||||
mysql_executor: &deps.mysql_pool,
|
||||
phantom: PhantomData,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(namespaces) => namespaces,
|
||||
Err(err) => {
|
||||
error!("User-summaries: listing namespaces failed: {err:?}");
|
||||
alert_pager(&deps.pager, "user-spend-analytics: namespace list error", &format!("{err:?}"));
|
||||
let _ = deps.job_stats.increment_failure_count();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut total_upserted = 0u64;
|
||||
for namespace in namespaces {
|
||||
total_upserted += backfill_namespace(deps, namespace, now).await;
|
||||
if deps.application_shutdown.get() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
info!("User-summaries backfill complete: {total_upserted} user(s) upserted.");
|
||||
}
|
||||
|
||||
async fn backfill_namespace(deps: &JobDependencies, namespace: PaymentsNamespace, now: DateTime<Utc>) -> u64 {
|
||||
info!("User-summaries: backfilling namespace '{}'…", namespace.to_str());
|
||||
let mut after_user_token = String::new();
|
||||
let mut upserted = 0u64;
|
||||
let mut processed = 0u64;
|
||||
|
||||
loop {
|
||||
if deps.application_shutdown.get() {
|
||||
return upserted;
|
||||
}
|
||||
let user_tokens = match list_user_tokens_with_spend_activity(ListUserTokensWithSpendActivityArgs {
|
||||
payments_namespace: namespace,
|
||||
after_user_token: &after_user_token,
|
||||
limit: deps.summary_user_page_size,
|
||||
mysql_executor: &deps.mysql_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(user_tokens) => user_tokens,
|
||||
Err(err) => {
|
||||
error!("User-summaries: listing users for {} failed: {err:?}", namespace.to_str());
|
||||
alert_pager(&deps.pager, "user-spend-analytics: user list error", &format!("ns={}: {err:?}", namespace.to_str()));
|
||||
let _ = deps.job_stats.increment_failure_count();
|
||||
return upserted;
|
||||
}
|
||||
};
|
||||
|
||||
if user_tokens.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for user_token in &user_tokens {
|
||||
if deps.application_shutdown.get() {
|
||||
return upserted;
|
||||
}
|
||||
match process_user(deps, namespace, user_token, now).await {
|
||||
Ok(()) => {
|
||||
upserted += 1;
|
||||
let _ = deps.job_stats.increment_success_count();
|
||||
}
|
||||
Err(err) => {
|
||||
error!("User-summaries: failed for {} ({}): {err:?}", user_token.as_str(), namespace.to_str());
|
||||
alert_pager(
|
||||
&deps.pager,
|
||||
"user-spend-analytics: summary error",
|
||||
&format!("user={} ns={}: {err:?}", user_token.as_str(), namespace.to_str()),
|
||||
);
|
||||
let _ = deps.job_stats.increment_failure_count();
|
||||
tokio::time::sleep(deps.error_recovery).await;
|
||||
}
|
||||
}
|
||||
processed += 1;
|
||||
if processed % SUMMARY_LOG_EVERY == 0 {
|
||||
info!(
|
||||
" user-summaries [{}]: {processed} processed, {upserted} upserted so far…",
|
||||
namespace.to_str(),
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(deps.query_delay).await;
|
||||
}
|
||||
|
||||
after_user_token = user_tokens.last().expect("non-empty page").as_str().to_string();
|
||||
}
|
||||
|
||||
info!(
|
||||
"User-summaries: namespace '{}' done — {processed} processed, {upserted} upserted.",
|
||||
namespace.to_str(),
|
||||
);
|
||||
upserted
|
||||
}
|
||||
|
||||
async fn process_user(
|
||||
deps: &JobDependencies,
|
||||
namespace: PaymentsNamespace,
|
||||
user_token: &UserToken,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let events = list_user_spend_events_for_user(ListUserSpendEventsForUserArgs {
|
||||
user_token,
|
||||
payments_namespace: namespace,
|
||||
mysql_executor: &deps.mysql_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let aggregate = compute_summary(&events, now);
|
||||
|
||||
// Authoritative subscription state (from user_subscriptions, not the events).
|
||||
let subscription_row = get_active_subscription_status_for_user(GetActiveSubscriptionStatusArgs {
|
||||
user_token,
|
||||
subscription_namespace: namespace,
|
||||
mysql_executor: &deps.mysql_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?;
|
||||
let (is_active_subscriber, maybe_subscription_interval) = match subscription_row {
|
||||
Some(row) => {
|
||||
let active = matches!(
|
||||
row.maybe_stripe_subscription_status.as_deref(),
|
||||
Some("active") | Some("trialing")
|
||||
) && row.subscription_expires_at > now;
|
||||
(active, row.maybe_stripe_recurring_interval)
|
||||
}
|
||||
None => (false, None),
|
||||
};
|
||||
|
||||
let score = reengagement_score(
|
||||
aggregate.lifetime_net_spend_usd_cents,
|
||||
aggregate.maybe_days_since_last_payment,
|
||||
&deps.reengagement,
|
||||
);
|
||||
|
||||
upsert_user_spend_summary(UpsertUserSpendSummaryArgs {
|
||||
payments_namespace: namespace,
|
||||
user_token,
|
||||
lifetime_gross_spend_usd_cents: aggregate.lifetime_gross_spend_usd_cents,
|
||||
lifetime_subscription_spend_usd_cents: aggregate.lifetime_subscription_spend_usd_cents,
|
||||
lifetime_credits_spend_usd_cents: aggregate.lifetime_credits_spend_usd_cents,
|
||||
lifetime_refund_usd_cents: aggregate.lifetime_refund_usd_cents,
|
||||
lifetime_net_spend_usd_cents: aggregate.lifetime_net_spend_usd_cents,
|
||||
lifetime_payment_count: aggregate.lifetime_payment_count,
|
||||
lifetime_refund_count: aggregate.lifetime_refund_count,
|
||||
maybe_first_payment_at: aggregate.maybe_first_payment_at,
|
||||
first_spend_usd_cents: aggregate.first_spend_usd_cents,
|
||||
maybe_last_payment_at: aggregate.maybe_last_payment_at,
|
||||
last_spend_usd_cents: aggregate.last_spend_usd_cents,
|
||||
maybe_days_since_first_payment: aggregate.maybe_days_since_first_payment,
|
||||
maybe_days_since_last_payment: aggregate.maybe_days_since_last_payment,
|
||||
net_spend_7d_usd_cents: aggregate.net_spend_7d_usd_cents,
|
||||
net_spend_prev_7d_usd_cents: aggregate.net_spend_prev_7d_usd_cents,
|
||||
net_spend_14d_usd_cents: aggregate.net_spend_14d_usd_cents,
|
||||
net_spend_prev_14d_usd_cents: aggregate.net_spend_prev_14d_usd_cents,
|
||||
net_spend_30d_usd_cents: aggregate.net_spend_30d_usd_cents,
|
||||
net_spend_prev_30d_usd_cents: aggregate.net_spend_prev_30d_usd_cents,
|
||||
net_spend_60d_usd_cents: aggregate.net_spend_60d_usd_cents,
|
||||
net_spend_90d_usd_cents: aggregate.net_spend_90d_usd_cents,
|
||||
net_spend_this_year_usd_cents: aggregate.net_spend_this_year_usd_cents,
|
||||
avg_weekly_net_spend_4w_usd_cents: aggregate.avg_weekly_net_spend_4w_usd_cents,
|
||||
avg_weekly_net_spend_12w_usd_cents: aggregate.avg_weekly_net_spend_12w_usd_cents,
|
||||
active_weeks_in_last_4: aggregate.active_weeks_in_last_4,
|
||||
active_weeks_in_last_8: aggregate.active_weeks_in_last_8,
|
||||
active_weeks_in_last_12: aggregate.active_weeks_in_last_12,
|
||||
active_weeks_in_last_24: aggregate.active_weeks_in_last_24,
|
||||
active_weeks_in_last_52: aggregate.active_weeks_in_last_52,
|
||||
consecutive_active_weeks: aggregate.consecutive_active_weeks,
|
||||
consecutive_inactive_weeks: aggregate.consecutive_inactive_weeks,
|
||||
maybe_weeks_since_last_spend: aggregate.maybe_weeks_since_last_spend,
|
||||
is_active_subscriber,
|
||||
maybe_subscription_interval: maybe_subscription_interval.as_deref(),
|
||||
maybe_reengagement_score: Some(score),
|
||||
mysql_executor: &deps.mysql_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derived summary fields, computed from a user's full event history.
|
||||
struct SummaryAggregate {
|
||||
lifetime_gross_spend_usd_cents: u64,
|
||||
lifetime_subscription_spend_usd_cents: u64,
|
||||
lifetime_credits_spend_usd_cents: u64,
|
||||
lifetime_refund_usd_cents: u64,
|
||||
lifetime_net_spend_usd_cents: u64,
|
||||
lifetime_payment_count: u32,
|
||||
lifetime_refund_count: u32,
|
||||
maybe_first_payment_at: Option<DateTime<Utc>>,
|
||||
first_spend_usd_cents: u64,
|
||||
maybe_last_payment_at: Option<DateTime<Utc>>,
|
||||
last_spend_usd_cents: u64,
|
||||
maybe_days_since_first_payment: Option<u32>,
|
||||
maybe_days_since_last_payment: Option<u32>,
|
||||
net_spend_7d_usd_cents: i64,
|
||||
net_spend_prev_7d_usd_cents: i64,
|
||||
net_spend_14d_usd_cents: i64,
|
||||
net_spend_prev_14d_usd_cents: i64,
|
||||
net_spend_30d_usd_cents: i64,
|
||||
net_spend_prev_30d_usd_cents: i64,
|
||||
net_spend_60d_usd_cents: i64,
|
||||
net_spend_90d_usd_cents: i64,
|
||||
net_spend_this_year_usd_cents: i64,
|
||||
avg_weekly_net_spend_4w_usd_cents: i64,
|
||||
avg_weekly_net_spend_12w_usd_cents: i64,
|
||||
active_weeks_in_last_4: u8,
|
||||
active_weeks_in_last_8: u8,
|
||||
active_weeks_in_last_12: u8,
|
||||
active_weeks_in_last_24: u8,
|
||||
active_weeks_in_last_52: u8,
|
||||
consecutive_active_weeks: u32,
|
||||
consecutive_inactive_weeks: u32,
|
||||
maybe_weeks_since_last_spend: Option<u32>,
|
||||
}
|
||||
|
||||
/// Aggregate one user's events (ascending by `payment_occurred_at`).
|
||||
///
|
||||
/// - Lifetime totals + first/last from positive (payment) and negative (refund) events.
|
||||
/// - Sliding NET windows are day-rolling relative to `now` (7d = last 7×24h, etc.).
|
||||
/// - Weekly cadence (avg-weekly, active-weeks, streaks) uses Mon–Sun UTC calendar
|
||||
/// weeks; week 0 is the calendar week containing `now`.
|
||||
fn compute_summary(events: &[UserSpendEventRow], now: DateTime<Utc>) -> SummaryAggregate {
|
||||
let current_year = now.year();
|
||||
let now_monday = monday_of(now.date_naive());
|
||||
|
||||
let mut gross: i128 = 0;
|
||||
let mut subscription: i128 = 0;
|
||||
let mut credits: i128 = 0;
|
||||
let mut refund: i128 = 0;
|
||||
let mut net: i128 = 0;
|
||||
let mut net_this_year: i128 = 0;
|
||||
let mut payment_count: u32 = 0;
|
||||
let mut refund_count: u32 = 0;
|
||||
let mut first_payment: Option<&UserSpendEventRow> = None;
|
||||
let mut last_payment: Option<&UserSpendEventRow> = None;
|
||||
|
||||
// Day-rolling net windows.
|
||||
let (mut w7, mut pw7, mut w14, mut pw14, mut w30, mut pw30, mut w60, mut w90): (i128, i128, i128, i128, i128, i128, i128, i128) = (0, 0, 0, 0, 0, 0, 0, 0);
|
||||
|
||||
// Calendar-week buckets: week_index -> net; and the set of weeks with a payment.
|
||||
let mut week_net: HashMap<i64, i128> = HashMap::new();
|
||||
let mut active_weeks: HashSet<i64> = HashSet::new();
|
||||
|
||||
for event in events {
|
||||
let amount = event.amount_usd_cents as i128;
|
||||
net += amount;
|
||||
if event.payment_occurred_at.year() == current_year {
|
||||
net_this_year += amount;
|
||||
}
|
||||
|
||||
let days_ago = (now - event.payment_occurred_at).num_days();
|
||||
if days_ago >= 0 {
|
||||
if days_ago < 7 { w7 += amount; }
|
||||
if (7..14).contains(&days_ago) { pw7 += amount; }
|
||||
if days_ago < 14 { w14 += amount; }
|
||||
if (14..28).contains(&days_ago) { pw14 += amount; }
|
||||
if days_ago < 30 { w30 += amount; }
|
||||
if (30..60).contains(&days_ago) { pw30 += amount; }
|
||||
if days_ago < 60 { w60 += amount; }
|
||||
if days_ago < 90 { w90 += amount; }
|
||||
}
|
||||
|
||||
let week_index = (now_monday - monday_of(event.payment_occurred_at.date_naive())).num_days() / DAYS_PER_WEEK;
|
||||
if week_index >= 0 {
|
||||
*week_net.entry(week_index).or_insert(0) += amount;
|
||||
}
|
||||
|
||||
if event.amount_usd_cents > 0 {
|
||||
gross += amount;
|
||||
payment_count += 1;
|
||||
match event.event_type {
|
||||
PaymentEventType::SubscriptionInitial
|
||||
| PaymentEventType::SubscriptionRenewal
|
||||
| PaymentEventType::SubscriptionProrationUpgrade => subscription += amount,
|
||||
PaymentEventType::CreditPackPurchase => credits += amount,
|
||||
_ => {}
|
||||
}
|
||||
if first_payment.is_none() {
|
||||
first_payment = Some(event);
|
||||
}
|
||||
last_payment = Some(event);
|
||||
if week_index >= 0 {
|
||||
active_weeks.insert(week_index);
|
||||
}
|
||||
} else if event.amount_usd_cents < 0 {
|
||||
refund += -amount;
|
||||
refund_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let maybe_first_payment_at = first_payment.map(|e| e.payment_occurred_at);
|
||||
let maybe_last_payment_at = last_payment.map(|e| e.payment_occurred_at);
|
||||
let first_spend_usd_cents = first_payment.map(|e| e.amount_usd_cents.max(0) as u64).unwrap_or(0);
|
||||
let last_spend_usd_cents = last_payment.map(|e| e.amount_usd_cents.max(0) as u64).unwrap_or(0);
|
||||
let maybe_days_since_first_payment = maybe_first_payment_at.map(|t| (now - t).num_days().max(0) as u32);
|
||||
let maybe_days_since_last_payment = maybe_last_payment_at.map(|t| (now - t).num_days().max(0) as u32);
|
||||
|
||||
// Calendar-week cadence.
|
||||
let maybe_weeks_since_last_spend = active_weeks.iter().min().map(|w| *w as u32);
|
||||
let consecutive_active_weeks = {
|
||||
let mut week = 0i64;
|
||||
while active_weeks.contains(&week) {
|
||||
week += 1;
|
||||
}
|
||||
week as u32
|
||||
};
|
||||
let consecutive_inactive_weeks = maybe_weeks_since_last_spend.unwrap_or(0);
|
||||
|
||||
let active_in_last = |n: i64| active_weeks.iter().filter(|&&w| (0..n).contains(&w)).count() as u8;
|
||||
let week_net_sum = |n: i64| -> i128 { (0..n).map(|w| *week_net.get(&w).unwrap_or(&0)).sum() };
|
||||
|
||||
SummaryAggregate {
|
||||
lifetime_gross_spend_usd_cents: gross.max(0) as u64,
|
||||
lifetime_subscription_spend_usd_cents: subscription.max(0) as u64,
|
||||
lifetime_credits_spend_usd_cents: credits.max(0) as u64,
|
||||
lifetime_refund_usd_cents: refund.max(0) as u64,
|
||||
lifetime_net_spend_usd_cents: net.max(0) as u64,
|
||||
lifetime_payment_count: payment_count,
|
||||
lifetime_refund_count: refund_count,
|
||||
maybe_first_payment_at,
|
||||
first_spend_usd_cents,
|
||||
maybe_last_payment_at,
|
||||
last_spend_usd_cents,
|
||||
maybe_days_since_first_payment,
|
||||
maybe_days_since_last_payment,
|
||||
net_spend_7d_usd_cents: clamp_i64(w7),
|
||||
net_spend_prev_7d_usd_cents: clamp_i64(pw7),
|
||||
net_spend_14d_usd_cents: clamp_i64(w14),
|
||||
net_spend_prev_14d_usd_cents: clamp_i64(pw14),
|
||||
net_spend_30d_usd_cents: clamp_i64(w30),
|
||||
net_spend_prev_30d_usd_cents: clamp_i64(pw30),
|
||||
net_spend_60d_usd_cents: clamp_i64(w60),
|
||||
net_spend_90d_usd_cents: clamp_i64(w90),
|
||||
net_spend_this_year_usd_cents: clamp_i64(net_this_year),
|
||||
avg_weekly_net_spend_4w_usd_cents: clamp_i64(week_net_sum(4) / 4),
|
||||
avg_weekly_net_spend_12w_usd_cents: clamp_i64(week_net_sum(12) / 12),
|
||||
active_weeks_in_last_4: active_in_last(4),
|
||||
active_weeks_in_last_8: active_in_last(8),
|
||||
active_weeks_in_last_12: active_in_last(12),
|
||||
active_weeks_in_last_24: active_in_last(24),
|
||||
active_weeks_in_last_52: active_in_last(52),
|
||||
consecutive_active_weeks,
|
||||
consecutive_inactive_weeks,
|
||||
maybe_weeks_since_last_spend,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Monday (UTC) of the calendar week containing `date`.
|
||||
fn monday_of(date: chrono::NaiveDate) -> chrono::NaiveDate {
|
||||
date - Duration::days(date.weekday().num_days_from_monday() as i64)
|
||||
}
|
||||
|
||||
fn clamp_i64(value: i128) -> i64 {
|
||||
value.clamp(i64::MIN as i128, i64::MAX as i128) as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::TimeZone;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn event(days_ago: i64, amount_usd_cents: i64, event_type: PaymentEventType, now: DateTime<Utc>) -> UserSpendEventRow {
|
||||
UserSpendEventRow {
|
||||
payment_occurred_at: now - Duration::days(days_ago),
|
||||
amount_usd_cents,
|
||||
event_type,
|
||||
maybe_credits_granted: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifetime_and_day_windows() {
|
||||
let now = Utc::now();
|
||||
let events = vec![
|
||||
event(40, 1999, PaymentEventType::SubscriptionInitial, now),
|
||||
event(20, 999, PaymentEventType::CreditPackPurchase, now),
|
||||
event(5, 2999, PaymentEventType::SubscriptionRenewal, now),
|
||||
event(3, -999, PaymentEventType::Refund, now),
|
||||
];
|
||||
let agg = compute_summary(&events, now);
|
||||
|
||||
assert_eq!(agg.lifetime_gross_spend_usd_cents, 1999 + 999 + 2999);
|
||||
assert_eq!(agg.lifetime_refund_usd_cents, 999);
|
||||
assert_eq!(agg.lifetime_net_spend_usd_cents, (1999 + 999 + 2999 - 999) as u64);
|
||||
assert_eq!(agg.lifetime_payment_count, 3);
|
||||
assert_eq!(agg.lifetime_refund_count, 1);
|
||||
// 7d window: day5 (+2999) and day3 (-999) -> 2000
|
||||
assert_eq!(agg.net_spend_7d_usd_cents, 2000);
|
||||
// 30d window: day20 (+999) + day5 (+2999) + day3 (-999) -> 2999
|
||||
assert_eq!(agg.net_spend_30d_usd_cents, 2999);
|
||||
// prev_30d: 30..60 -> day40 (+1999)
|
||||
assert_eq!(agg.net_spend_prev_30d_usd_cents, 1999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_week_cadence() {
|
||||
// Anchor `now` on a Monday so week boundaries are deterministic.
|
||||
let today = Utc::now().date_naive();
|
||||
let monday = monday_of(today);
|
||||
let now = Utc.from_utc_datetime(&monday.and_hms_opt(12, 0, 0).unwrap());
|
||||
// Payments on three consecutive Mondays => weeks 0,1,2 active.
|
||||
let events = vec![
|
||||
event(14, 1000, PaymentEventType::CreditPackPurchase, now),
|
||||
event(7, 1000, PaymentEventType::CreditPackPurchase, now),
|
||||
event(0, 1000, PaymentEventType::CreditPackPurchase, now),
|
||||
];
|
||||
let agg = compute_summary(&events, now);
|
||||
|
||||
assert_eq!(agg.maybe_weeks_since_last_spend, Some(0));
|
||||
assert_eq!(agg.consecutive_active_weeks, 3);
|
||||
assert_eq!(agg.consecutive_inactive_weeks, 0);
|
||||
assert_eq!(agg.active_weeks_in_last_4, 3);
|
||||
assert_eq!(agg.active_weeks_in_last_52, 3);
|
||||
// weeks 0..3 net = 1000+1000+1000+0 = 3000; /4 = 750
|
||||
assert_eq!(agg.avg_weekly_net_spend_4w_usd_cents, 750);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lapsed_user_streaks() {
|
||||
let today = Utc::now().date_naive();
|
||||
let monday = monday_of(today);
|
||||
let now = Utc.from_utc_datetime(&monday.and_hms_opt(12, 0, 0).unwrap());
|
||||
// Last payment 5 weeks ago (week 5).
|
||||
let events = vec![event(35, 4999, PaymentEventType::SubscriptionInitial, now)];
|
||||
let agg = compute_summary(&events, now);
|
||||
assert_eq!(agg.maybe_weeks_since_last_spend, Some(5));
|
||||
assert_eq!(agg.consecutive_inactive_weeks, 5);
|
||||
assert_eq!(agg.consecutive_active_weeks, 0);
|
||||
assert_eq!(agg.active_weeks_in_last_4, 0);
|
||||
assert_eq!(agg.active_weeks_in_last_8, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{info, warn};
|
||||
|
||||
use crate::job::backfill_daily_spends::backfill_daily_spends;
|
||||
use crate::job::backfill_user_summaries::backfill_user_summaries;
|
||||
use crate::job_dependencies::JobDependencies;
|
||||
|
||||
const SHUTDOWN_POLL_SLICE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Run the two backfill tasks in order each cycle, then sleep. Errors inside the
|
||||
/// tasks are paged and skipped (they never abort the cycle).
|
||||
pub async fn main_loop(deps: JobDependencies) {
|
||||
while !deps.application_shutdown.get() {
|
||||
info!("==== Starting spend-analytics cycle ====");
|
||||
|
||||
backfill_daily_spends(&deps).await;
|
||||
if deps.application_shutdown.get() {
|
||||
break;
|
||||
}
|
||||
backfill_user_summaries(&deps).await;
|
||||
|
||||
info!("Cycle complete. Sleeping {:?} before next cycle.", deps.sleep_between_cycles);
|
||||
sleep_interruptible(&deps, deps.sleep_between_cycles).await;
|
||||
}
|
||||
|
||||
warn!("User spend analytics job main loop is shut down.");
|
||||
}
|
||||
|
||||
/// Sleep in small slices so a shutdown signal is honored promptly.
|
||||
async fn sleep_interruptible(deps: &JobDependencies, total: Duration) {
|
||||
let mut remaining = total;
|
||||
while remaining > Duration::ZERO && !deps.application_shutdown.get() {
|
||||
let nap = remaining.min(SHUTDOWN_POLL_SLICE);
|
||||
tokio::time::sleep(nap).await;
|
||||
remaining = remaining.saturating_sub(nap);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod alert_on_error;
|
||||
pub mod backfill_daily_spends;
|
||||
pub mod backfill_user_summaries;
|
||||
pub mod main_loop;
|
||||
pub mod reengagement_score;
|
||||
@@ -1,86 +0,0 @@
|
||||
use crate::job_dependencies::ReengagementConfig;
|
||||
|
||||
const MAX_SCORE: f64 = 1000.0;
|
||||
const MAX_VALUE_COMPONENT: f64 = 700.0;
|
||||
const MAX_RECENCY_COMPONENT: f64 = 300.0;
|
||||
|
||||
/// Re-engagement score in `0..=1000`; higher = better re-engagement target.
|
||||
///
|
||||
/// `0` means "not a target": still active, never paid, or no net spend. Otherwise
|
||||
/// the score is a value component (dominant, log-scaled lifetime net spend) plus a
|
||||
/// recency component that decays with time since the user lapsed. A big spender who
|
||||
/// lapsed a while ago still outranks a small spender who just lapsed.
|
||||
pub fn reengagement_score(
|
||||
lifetime_net_spend_usd_cents: u64,
|
||||
maybe_days_since_last_payment: Option<u32>,
|
||||
config: &ReengagementConfig,
|
||||
) -> u32 {
|
||||
let days_since_last = match maybe_days_since_last_payment {
|
||||
Some(days) => days,
|
||||
None => return 0, // never paid -> not a re-engagement target
|
||||
};
|
||||
|
||||
if lifetime_net_spend_usd_cents == 0 {
|
||||
return 0;
|
||||
}
|
||||
if days_since_last < config.active_threshold_days {
|
||||
return 0; // still active -> no need to re-engage
|
||||
}
|
||||
|
||||
let dollars = lifetime_net_spend_usd_cents as f64 / 100.0;
|
||||
let value_component = (MAX_VALUE_COMPONENT * (1.0 + dollars).ln()
|
||||
/ (1.0 + config.value_cap_dollars).ln())
|
||||
.clamp(0.0, MAX_VALUE_COMPONENT);
|
||||
|
||||
let days_lapsed = (days_since_last - config.active_threshold_days) as f64;
|
||||
let recency_fraction = (1.0 - days_lapsed / config.decay_days).max(0.0);
|
||||
let recency_component = MAX_RECENCY_COMPONENT * recency_fraction;
|
||||
|
||||
(value_component + recency_component).round().clamp(0.0, MAX_SCORE) as u32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CONFIG: ReengagementConfig = ReengagementConfig {
|
||||
value_cap_dollars: 1000.0,
|
||||
active_threshold_days: 14,
|
||||
decay_days: 350.0,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn never_paid_scores_zero() {
|
||||
assert_eq!(reengagement_score(50_000, None, &CONFIG), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_active_scores_zero() {
|
||||
assert_eq!(reengagement_score(50_000, Some(3), &CONFIG), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_net_spend_scores_zero() {
|
||||
assert_eq!(reengagement_score(0, Some(90), &CONFIG), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_recently_lapsed_outranks_whale_long_lapsed() {
|
||||
let recent = reengagement_score(100_000, Some(35), &CONFIG); // $1000, ~3wks
|
||||
let old = reengagement_score(100_000, Some(379), &CONFIG); // $1000, ~1yr
|
||||
assert!(recent > old, "recent={recent} old={old}");
|
||||
assert!(recent > 900, "recent={recent}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_long_lapsed_outranks_small_recent() {
|
||||
let whale_old = reengagement_score(100_000, Some(379), &CONFIG); // $1000, ~1yr -> ~700
|
||||
let small_recent = reengagement_score(2_000, Some(35), &CONFIG); // $20, ~3wks
|
||||
assert!(whale_old > small_recent, "whale_old={whale_old} small_recent={small_recent}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_never_exceeds_1000() {
|
||||
assert!(reengagement_score(100_000_000, Some(15), &CONFIG) <= 1000);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user