Skip to main content

max / makenotwork

13.7 KB · 440 lines History Blame Raw
1 //! Internal creator dashboard: projects, stats, analytics, transactions, and sales export.
2
3 use crate::auth::InternalActor;
4 use axum::{
5 Json,
6 extract::{Path, Query, State},
7 response::IntoResponse,
8 };
9 use serde::{Deserialize, Serialize};
10 use std::fmt::Write as _;
11
12 use sqlx::PgPool;
13
14 use crate::{
15 auth::ServiceAuth,
16 db::{self, ItemId, ItemType, ProjectId, ProjectType, TransactionId},
17 error::{AppError, Result},
18 helpers,
19 };
20
21 // --- Creator projects ---
22
23 #[derive(Deserialize)]
24 pub(super) struct UserIdQuery {}
25
26 #[derive(Serialize)]
27 struct CreatorProject {
28 id: ProjectId,
29 slug: String,
30 title: String,
31 project_type: ProjectType,
32 is_public: bool,
33 item_count: i64,
34 /// Revenue in `currency`, which is the project's largest single-currency
35 /// total. Kept as a bare number so existing consumers keep working, but it
36 /// is only meaningful next to `currency`: a consumer that assumes USD is
37 /// wrong the moment the creator settles anywhere else.
38 revenue_cents: i64,
39 /// The ISO code `revenue_cents` is denominated in, lowercase.
40 currency: String,
41 /// Every currency this project earned in, for the uncommon case where a
42 /// creator's settlement currency changed and older sales are denominated in
43 /// the previous one. Normally a single entry matching `revenue_cents`.
44 revenue_cents_by_currency: std::collections::BTreeMap<String, i64>,
45 }
46
47 /// GET /api/internal/creator/projects?user_id={uuid}
48 ///
49 /// List all projects for a creator with item counts and revenue.
50 #[tracing::instrument(skip_all, name = "internal::creator_projects")]
51 pub(super) async fn creator_projects(
52 State(db): State<PgPool>,
53 actor: InternalActor,
54 _auth: ServiceAuth,
55 Query(_query): Query<UserIdQuery>,
56 ) -> Result<impl IntoResponse> {
57 let projects = db::projects::get_projects_by_user(&db, actor.user_id()).await?;
58 let revenue = db::transactions::get_revenue_by_user_projects(&db, actor.user_id()).await?;
59
60 // Build revenue lookup: project_id -> cents
61 let revenue_map: std::collections::HashMap<ProjectId, crate::currency::MoneyByCurrency> =
62 revenue
63 .into_iter()
64 .map(|(pid, _title, money)| (pid, money))
65 .collect();
66
67 // Count items per project in a single query
68 let item_counts = db::items::count_items_by_user_projects(&db, actor.user_id()).await?;
69 let count_map: std::collections::HashMap<ProjectId, i64> = item_counts.into_iter().collect();
70
71 let data: Vec<CreatorProject> = projects
72 .into_iter()
73 .map(|p| CreatorProject {
74 id: p.id,
75 slug: p.slug.to_string(),
76 title: p.title,
77 project_type: p.project_type,
78 is_public: p.is_public,
79 item_count: count_map.get(&p.id).copied().unwrap_or(0),
80 revenue_cents: money_for(&revenue_map, p.id).0,
81 currency: money_for(&revenue_map, p.id).1,
82 revenue_cents_by_currency: revenue_map.get(&p.id).map(by_currency).unwrap_or_default(),
83 })
84 .collect();
85
86 Ok(Json(data))
87 }
88
89 // --- Project items ---
90
91 #[derive(Serialize)]
92 struct CreatorItem {
93 id: ItemId,
94 title: String,
95 item_type: ItemType,
96 price_cents: i32,
97 is_public: bool,
98 sort_order: i32,
99 }
100
101 /// GET /api/internal/creator/projects/{id}/items?user_id={uuid}
102 ///
103 /// List items in a project (verifies ownership).
104 #[tracing::instrument(skip_all, name = "internal::creator_project_items")]
105 pub(super) async fn creator_project_items(
106 State(db): State<PgPool>,
107 actor: InternalActor,
108 _auth: ServiceAuth,
109 Path(project_id): Path<ProjectId>,
110 Query(_query): Query<UserIdQuery>,
111 ) -> Result<impl IntoResponse> {
112 // Verify ownership
113 let project = db::projects::get_project_by_id(&db, project_id)
114 .await?
115 .ok_or(AppError::NotFound)?;
116 if project.user_id != actor.user_id() {
117 return Err(AppError::Forbidden);
118 }
119
120 let items = db::items::get_items_by_project(&db, project_id).await?;
121
122 let data: Vec<CreatorItem> = items
123 .into_iter()
124 .map(|i| CreatorItem {
125 id: i.id,
126 title: i.title,
127 item_type: i.item_type,
128 price_cents: i.price_cents,
129 is_public: i.is_public,
130 sort_order: i.sort_order,
131 })
132 .collect();
133
134 Ok(Json(data))
135 }
136
137 // --- Creator stats ---
138
139 #[derive(Deserialize)]
140 pub(super) struct StatsQuery {
141 /// Time range: "7d", "30d", "90d", or "all"
142 #[serde(default = "default_range")]
143 range: String,
144 }
145
146 fn default_range() -> String {
147 "30d".to_string()
148 }
149
150 #[derive(Serialize)]
151 struct CreatorStats {
152 current_revenue_cents: i64,
153 previous_revenue_cents: i64,
154 current_sales: i64,
155 previous_sales: i64,
156 current_followers: i64,
157 previous_followers: i64,
158 total_projects: i64,
159 total_items: i64,
160 }
161
162 /// GET /api/internal/creator/stats?user_id={uuid}&range=30d
163 ///
164 /// Period comparison stats for the creator dashboard.
165 #[tracing::instrument(skip_all, name = "internal::creator_stats")]
166 pub(super) async fn creator_stats(
167 State(db): State<PgPool>,
168 actor: InternalActor,
169 _auth: ServiceAuth,
170 Query(query): Query<StatsQuery>,
171 ) -> Result<impl IntoResponse> {
172 let range: db::analytics::TimeRange = query
173 .range
174 .parse()
175 .map_err(|()| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?;
176
177 let comparison =
178 db::analytics::get_period_comparison(&db, actor.user_id(), None, None, &range).await?;
179
180 let total_projects = db::projects::count_projects_by_user(&db, actor.user_id()).await?;
181 let total_items = db::items::count_items_by_user(&db, actor.user_id()).await?;
182
183 Ok(Json(CreatorStats {
184 current_revenue_cents: comparison.current_revenue_cents.as_i64(),
185 previous_revenue_cents: comparison.previous_revenue_cents.as_i64(),
186 current_sales: comparison.current_sales,
187 previous_sales: comparison.previous_sales,
188 current_followers: comparison.current_followers,
189 previous_followers: comparison.previous_followers,
190 total_projects,
191 total_items,
192 }))
193 }
194
195 // --- Analytics ---
196
197 #[derive(Serialize)]
198 struct AnalyticsBucket {
199 label: String,
200 revenue_cents: i64,
201 sales_count: i64,
202 }
203
204 #[derive(Serialize)]
205 struct ProjectRevenueSummary {
206 id: ProjectId,
207 title: String,
208 /// Revenue in `currency`. See `CreatorProject::revenue_cents`.
209 revenue_cents: i64,
210 /// The ISO code `revenue_cents` is denominated in, lowercase.
211 currency: String,
212 /// Every currency this project earned in. See
213 /// `CreatorProject::revenue_cents_by_currency`.
214 revenue_cents_by_currency: std::collections::BTreeMap<String, i64>,
215 }
216
217 #[derive(Serialize)]
218 struct AnalyticsResponse {
219 buckets: Vec<AnalyticsBucket>,
220 current_revenue_cents: i64,
221 previous_revenue_cents: i64,
222 current_sales: i64,
223 previous_sales: i64,
224 current_followers: i64,
225 previous_followers: i64,
226 top_projects: Vec<ProjectRevenueSummary>,
227 }
228
229 /// GET /api/internal/creator/analytics?user_id={uuid}&range=30d
230 ///
231 /// Revenue timeseries, period comparison, and top projects.
232 #[tracing::instrument(skip_all, name = "internal::creator_analytics")]
233 pub(super) async fn creator_analytics(
234 State(db): State<PgPool>,
235 actor: InternalActor,
236 _auth: ServiceAuth,
237 Query(query): Query<StatsQuery>,
238 ) -> Result<impl IntoResponse> {
239 let range: db::analytics::TimeRange = query
240 .range
241 .parse()
242 .map_err(|()| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?;
243
244 let buckets =
245 db::analytics::get_revenue_timeseries(&db, actor.user_id(), None, None, &range).await?;
246 let comparison =
247 db::analytics::get_period_comparison(&db, actor.user_id(), None, None, &range).await?;
248 let revenue = db::transactions::get_revenue_by_user_projects(&db, actor.user_id()).await?;
249
250 let top_projects: Vec<ProjectRevenueSummary> = revenue
251 .into_iter()
252 .map(|(id, title, money)| {
253 let (revenue_cents, currency) = dominant(&money);
254 ProjectRevenueSummary {
255 id,
256 title,
257 revenue_cents,
258 currency,
259 revenue_cents_by_currency: by_currency(&money),
260 }
261 })
262 .collect();
263
264 Ok(Json(AnalyticsResponse {
265 buckets: buckets
266 .into_iter()
267 .map(|b| AnalyticsBucket {
268 label: b.label,
269 revenue_cents: b.revenue_cents.as_i64(),
270 sales_count: b.sales_count,
271 })
272 .collect(),
273 current_revenue_cents: comparison.current_revenue_cents.as_i64(),
274 previous_revenue_cents: comparison.previous_revenue_cents.as_i64(),
275 current_sales: comparison.current_sales,
276 previous_sales: comparison.previous_sales,
277 current_followers: comparison.current_followers,
278 previous_followers: comparison.previous_followers,
279 top_projects,
280 }))
281 }
282
283 // --- Transactions ---
284
285 #[derive(Serialize)]
286 struct TransactionResponse {
287 id: TransactionId,
288 item_title: Option<String>,
289 amount_cents: i32,
290 status: String,
291 created_at: String,
292 completed_at: Option<String>,
293 }
294
295 /// GET /api/internal/creator/transactions?user_id={uuid}
296 ///
297 /// Recent seller transactions (up to 100).
298 #[tracing::instrument(skip_all, name = "internal::creator_transactions")]
299 pub(super) async fn creator_transactions(
300 State(db): State<PgPool>,
301 actor: InternalActor,
302 _auth: ServiceAuth,
303 Query(_query): Query<UserIdQuery>,
304 ) -> Result<impl IntoResponse> {
305 let txs = db::transactions::get_transactions_by_seller(&db, actor.user_id(), Some(100)).await?;
306
307 let data: Vec<TransactionResponse> = txs
308 .into_iter()
309 .map(|t| TransactionResponse {
310 id: t.id,
311 item_title: t.item_title,
312 amount_cents: t.amount_cents.as_i64() as i32,
313 status: t.status.to_string(),
314 created_at: t.created_at.to_rfc3339(),
315 completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
316 })
317 .collect();
318
319 Ok(Json(data))
320 }
321
322 // --- Export sales CSV ---
323
324 /// GET /api/internal/creator/export/sales?user_id={uuid}
325 ///
326 /// Returns sales data as a CSV string.
327 #[tracing::instrument(skip_all, name = "internal::export_sales")]
328 pub(super) async fn export_sales(
329 State(db): State<PgPool>,
330 actor: InternalActor,
331 _auth: ServiceAuth,
332 Query(_query): Query<UserIdQuery>,
333 ) -> Result<impl IntoResponse> {
334 // Page the read so a whale seller's full history isn't loaded by one
335 // unbounded query; peak memory is one batch plus the accumulating CSV.
336 // Mirrors the streaming web export's batch size and row ceiling (ultra-fuzz
337 // Run 10 Perf S5).
338 const BATCH: i64 = 2_000;
339 const MAX_ROWS: usize = 1_000_000;
340
341 let mut csv = String::from("Date,Item ID,Item Title,Amount,Status,Buyer Email\n");
342 let mut offset = 0i64;
343 let mut total = 0usize;
344 loop {
345 let rows = db::transactions::get_seller_transactions_for_export_page(
346 &db,
347 actor.user_id(),
348 BATCH,
349 offset,
350 )
351 .await?;
352 let n = rows.len();
353 for tx in &rows {
354 let date = tx.created_at.format("%Y-%m-%d %H:%M:%S").to_string();
355 let item_id = tx.item_id.map(|id| id.to_string()).unwrap_or_default();
356 let item_title = tx.item_title.as_deref().unwrap_or("");
357 let amount = format!(
358 "{}.{:02}",
359 tx.amount_cents / 100,
360 tx.amount_cents.abs() % 100
361 );
362 let status = tx.status.to_string();
363 let email = tx.buyer_email.as_deref().unwrap_or("");
364
365 writeln!(
366 csv,
367 "{},{},{},{},{},{}",
368 helpers::sanitize_csv_cell(&date),
369 helpers::sanitize_csv_cell(&item_id),
370 helpers::sanitize_csv_cell(item_title),
371 amount,
372 helpers::sanitize_csv_cell(&status),
373 helpers::sanitize_csv_cell(email),
374 )
375 .unwrap();
376 }
377 offset += n as i64;
378 total += n;
379 if (n as i64) < BATCH {
380 break;
381 }
382 if total >= MAX_ROWS {
383 tracing::warn!(
384 user_id = %actor.user_id(),
385 rows = total,
386 "internal sales export hit the row ceiling; truncating"
387 );
388 break;
389 }
390 }
391
392 Ok(Json(serde_json::json!({ "csv": csv, "row_count": total })))
393 }
394
395 /// The largest single-currency total and its ISO code.
396 ///
397 /// "Largest" rather than a sum, because there is no rate that would make a sum
398 /// true. A project with no sales reports zero in USD, the platform default.
399 fn dominant(money: &crate::currency::MoneyByCurrency) -> (i64, String) {
400 money.iter().max_by_key(|(_, cents)| *cents).map_or_else(
401 || {
402 (
403 0,
404 crate::currency::SettlementCurrency::default()
405 .code()
406 .to_string(),
407 )
408 },
409 |(currency, cents)| (cents, currency.code().to_string()),
410 )
411 }
412
413 /// Every currency, keyed by lowercase ISO code.
414 fn by_currency(
415 money: &crate::currency::MoneyByCurrency,
416 ) -> std::collections::BTreeMap<String, i64> {
417 money
418 .iter()
419 .map(|(c, cents)| (c.code().to_string(), cents))
420 .collect()
421 }
422
423 /// `dominant` for a project id that may have no revenue row at all.
424 fn money_for(
425 map: &std::collections::HashMap<ProjectId, crate::currency::MoneyByCurrency>,
426 id: ProjectId,
427 ) -> (i64, String) {
428 map.get(&id).map_or_else(
429 || {
430 (
431 0,
432 crate::currency::SettlementCurrency::default()
433 .code()
434 .to_string(),
435 )
436 },
437 dominant,
438 )
439 }
440