Skip to main content

max / makenotwork

11.1 KB · 370 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_cents: i64,
35 }
36
37 /// GET /api/internal/creator/projects?user_id={uuid}
38 ///
39 /// List all projects for a creator with item counts and revenue.
40 #[tracing::instrument(skip_all, name = "internal::creator_projects")]
41 pub(super) async fn creator_projects(
42 State(db): State<PgPool>,
43 actor: InternalActor,
44 _auth: ServiceAuth,
45 Query(_query): Query<UserIdQuery>,
46 ) -> Result<impl IntoResponse> {
47 let projects = db::projects::get_projects_by_user(&db, actor.user_id()).await?;
48 let revenue = db::transactions::get_revenue_by_user_projects(&db, actor.user_id()).await?;
49
50 // Build revenue lookup: project_id -> cents
51 let revenue_map: std::collections::HashMap<ProjectId, i64> = revenue
52 .into_iter()
53 .map(|(pid, _title, cents)| (pid, cents))
54 .collect();
55
56 // Count items per project in a single query
57 let item_counts = db::items::count_items_by_user_projects(&db, actor.user_id()).await?;
58 let count_map: std::collections::HashMap<ProjectId, i64> = item_counts.into_iter().collect();
59
60 let data: Vec<CreatorProject> = projects
61 .into_iter()
62 .map(|p| CreatorProject {
63 id: p.id,
64 slug: p.slug.to_string(),
65 title: p.title,
66 project_type: p.project_type,
67 is_public: p.is_public,
68 item_count: count_map.get(&p.id).copied().unwrap_or(0),
69 revenue_cents: revenue_map.get(&p.id).copied().unwrap_or(0),
70 })
71 .collect();
72
73 Ok(Json(data))
74 }
75
76 // ── Project items ──
77
78 #[derive(Serialize)]
79 struct CreatorItem {
80 id: ItemId,
81 title: String,
82 item_type: ItemType,
83 price_cents: i32,
84 is_public: bool,
85 sort_order: i32,
86 }
87
88 /// GET /api/internal/creator/projects/{id}/items?user_id={uuid}
89 ///
90 /// List items in a project (verifies ownership).
91 #[tracing::instrument(skip_all, name = "internal::creator_project_items")]
92 pub(super) async fn creator_project_items(
93 State(db): State<PgPool>,
94 actor: InternalActor,
95 _auth: ServiceAuth,
96 Path(project_id): Path<ProjectId>,
97 Query(_query): Query<UserIdQuery>,
98 ) -> Result<impl IntoResponse> {
99 // Verify ownership
100 let project = db::projects::get_project_by_id(&db, project_id)
101 .await?
102 .ok_or(AppError::NotFound)?;
103 if project.user_id != actor.user_id() {
104 return Err(AppError::Forbidden);
105 }
106
107 let items = db::items::get_items_by_project(&db, project_id).await?;
108
109 let data: Vec<CreatorItem> = items
110 .into_iter()
111 .map(|i| CreatorItem {
112 id: i.id,
113 title: i.title,
114 item_type: i.item_type,
115 price_cents: i.price_cents,
116 is_public: i.is_public,
117 sort_order: i.sort_order,
118 })
119 .collect();
120
121 Ok(Json(data))
122 }
123
124 // ── Creator stats ──
125
126 #[derive(Deserialize)]
127 pub(super) struct StatsQuery {
128 /// Time range: "7d", "30d", "90d", or "all"
129 #[serde(default = "default_range")]
130 range: String,
131 }
132
133 fn default_range() -> String {
134 "30d".to_string()
135 }
136
137 #[derive(Serialize)]
138 struct CreatorStats {
139 current_revenue_cents: i64,
140 previous_revenue_cents: i64,
141 current_sales: i64,
142 previous_sales: i64,
143 current_followers: i64,
144 previous_followers: i64,
145 total_projects: i64,
146 total_items: i64,
147 }
148
149 /// GET /api/internal/creator/stats?user_id={uuid}&range=30d
150 ///
151 /// Period comparison stats for the creator dashboard.
152 #[tracing::instrument(skip_all, name = "internal::creator_stats")]
153 pub(super) async fn creator_stats(
154 State(db): State<PgPool>,
155 actor: InternalActor,
156 _auth: ServiceAuth,
157 Query(query): Query<StatsQuery>,
158 ) -> Result<impl IntoResponse> {
159 let range: db::analytics::TimeRange = query
160 .range
161 .parse()
162 .map_err(|()| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?;
163
164 let comparison =
165 db::analytics::get_period_comparison(&db, actor.user_id(), None, None, &range).await?;
166
167 let total_projects = db::projects::count_projects_by_user(&db, actor.user_id()).await?;
168 let total_items = db::items::count_items_by_user(&db, actor.user_id()).await?;
169
170 Ok(Json(CreatorStats {
171 current_revenue_cents: comparison.current_revenue_cents.as_i64(),
172 previous_revenue_cents: comparison.previous_revenue_cents.as_i64(),
173 current_sales: comparison.current_sales,
174 previous_sales: comparison.previous_sales,
175 current_followers: comparison.current_followers,
176 previous_followers: comparison.previous_followers,
177 total_projects,
178 total_items,
179 }))
180 }
181
182 // ── Analytics ──
183
184 #[derive(Serialize)]
185 struct AnalyticsBucket {
186 label: String,
187 revenue_cents: i64,
188 sales_count: i64,
189 }
190
191 #[derive(Serialize)]
192 struct ProjectRevenueSummary {
193 id: ProjectId,
194 title: String,
195 revenue_cents: i64,
196 }
197
198 #[derive(Serialize)]
199 struct AnalyticsResponse {
200 buckets: Vec<AnalyticsBucket>,
201 current_revenue_cents: i64,
202 previous_revenue_cents: i64,
203 current_sales: i64,
204 previous_sales: i64,
205 current_followers: i64,
206 previous_followers: i64,
207 top_projects: Vec<ProjectRevenueSummary>,
208 }
209
210 /// GET /api/internal/creator/analytics?user_id={uuid}&range=30d
211 ///
212 /// Revenue timeseries, period comparison, and top projects.
213 #[tracing::instrument(skip_all, name = "internal::creator_analytics")]
214 pub(super) async fn creator_analytics(
215 State(db): State<PgPool>,
216 actor: InternalActor,
217 _auth: ServiceAuth,
218 Query(query): Query<StatsQuery>,
219 ) -> Result<impl IntoResponse> {
220 let range: db::analytics::TimeRange = query
221 .range
222 .parse()
223 .map_err(|()| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?;
224
225 let buckets =
226 db::analytics::get_revenue_timeseries(&db, actor.user_id(), None, None, &range).await?;
227 let comparison =
228 db::analytics::get_period_comparison(&db, actor.user_id(), None, None, &range).await?;
229 let revenue = db::transactions::get_revenue_by_user_projects(&db, actor.user_id()).await?;
230
231 let top_projects: Vec<ProjectRevenueSummary> = revenue
232 .into_iter()
233 .map(|(id, title, cents)| ProjectRevenueSummary {
234 id,
235 title,
236 revenue_cents: cents,
237 })
238 .collect();
239
240 Ok(Json(AnalyticsResponse {
241 buckets: buckets
242 .into_iter()
243 .map(|b| AnalyticsBucket {
244 label: b.label,
245 revenue_cents: b.revenue_cents.as_i64(),
246 sales_count: b.sales_count,
247 })
248 .collect(),
249 current_revenue_cents: comparison.current_revenue_cents.as_i64(),
250 previous_revenue_cents: comparison.previous_revenue_cents.as_i64(),
251 current_sales: comparison.current_sales,
252 previous_sales: comparison.previous_sales,
253 current_followers: comparison.current_followers,
254 previous_followers: comparison.previous_followers,
255 top_projects,
256 }))
257 }
258
259 // ── Transactions ──
260
261 #[derive(Serialize)]
262 struct TransactionResponse {
263 id: TransactionId,
264 item_title: Option<String>,
265 amount_cents: i32,
266 status: String,
267 created_at: String,
268 completed_at: Option<String>,
269 }
270
271 /// GET /api/internal/creator/transactions?user_id={uuid}
272 ///
273 /// Recent seller transactions (up to 100).
274 #[tracing::instrument(skip_all, name = "internal::creator_transactions")]
275 pub(super) async fn creator_transactions(
276 State(db): State<PgPool>,
277 actor: InternalActor,
278 _auth: ServiceAuth,
279 Query(_query): Query<UserIdQuery>,
280 ) -> Result<impl IntoResponse> {
281 let txs = db::transactions::get_transactions_by_seller(&db, actor.user_id(), Some(100)).await?;
282
283 let data: Vec<TransactionResponse> = txs
284 .into_iter()
285 .map(|t| TransactionResponse {
286 id: t.id,
287 item_title: t.item_title,
288 amount_cents: t.amount_cents.as_i64() as i32,
289 status: t.status.to_string(),
290 created_at: t.created_at.to_rfc3339(),
291 completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
292 })
293 .collect();
294
295 Ok(Json(data))
296 }
297
298 // ── Export sales CSV ──
299
300 /// GET /api/internal/creator/export/sales?user_id={uuid}
301 ///
302 /// Returns sales data as a CSV string.
303 #[tracing::instrument(skip_all, name = "internal::export_sales")]
304 pub(super) async fn export_sales(
305 State(db): State<PgPool>,
306 actor: InternalActor,
307 _auth: ServiceAuth,
308 Query(_query): Query<UserIdQuery>,
309 ) -> Result<impl IntoResponse> {
310 // Page the read so a whale seller's full history isn't loaded by one
311 // unbounded query; peak memory is one batch plus the accumulating CSV.
312 // Mirrors the streaming web export's batch size and row ceiling (ultra-fuzz
313 // Run 10 Perf S5).
314 const BATCH: i64 = 2_000;
315 const MAX_ROWS: usize = 1_000_000;
316
317 let mut csv = String::from("Date,Item ID,Item Title,Amount,Status,Buyer Email\n");
318 let mut offset = 0i64;
319 let mut total = 0usize;
320 loop {
321 let rows = db::transactions::get_seller_transactions_for_export_page(
322 &db,
323 actor.user_id(),
324 BATCH,
325 offset,
326 )
327 .await?;
328 let n = rows.len();
329 for tx in &rows {
330 let date = tx.created_at.format("%Y-%m-%d %H:%M:%S").to_string();
331 let item_id = tx.item_id.map(|id| id.to_string()).unwrap_or_default();
332 let item_title = tx.item_title.as_deref().unwrap_or("");
333 let amount = format!(
334 "{}.{:02}",
335 tx.amount_cents / 100,
336 tx.amount_cents.abs() % 100
337 );
338 let status = tx.status.to_string();
339 let email = tx.buyer_email.as_deref().unwrap_or("");
340
341 writeln!(
342 csv,
343 "{},{},{},{},{},{}",
344 helpers::sanitize_csv_cell(&date),
345 helpers::sanitize_csv_cell(&item_id),
346 helpers::sanitize_csv_cell(item_title),
347 amount,
348 helpers::sanitize_csv_cell(&status),
349 helpers::sanitize_csv_cell(email),
350 )
351 .unwrap();
352 }
353 offset += n as i64;
354 total += n;
355 if (n as i64) < BATCH {
356 break;
357 }
358 if total >= MAX_ROWS {
359 tracing::warn!(
360 user_id = %actor.user_id(),
361 rows = total,
362 "internal sales export hit the row ceiling; truncating"
363 );
364 break;
365 }
366 }
367
368 Ok(Json(serde_json::json!({ "csv": csv, "row_count": total })))
369 }
370