Skip to main content

max / makenotwork

10.1 KB · 335 lines History Blame Raw
1 //! Internal creator dashboard: projects, stats, analytics, transactions, and sales export.
2
3 use axum::{
4 extract::{Path, Query, State},
5 response::IntoResponse,
6 Json,
7 };
8 use serde::{Deserialize, Serialize};
9
10 use crate::{
11 auth::ServiceAuth,
12 db::{self, ItemId, ItemType, ProjectId, ProjectType, TransactionId, UserId},
13 error::{AppError, Result},
14 helpers,
15 AppState,
16 };
17
18 // ── Creator projects ──
19
20 #[derive(Deserialize)]
21 pub(super) struct UserIdQuery {
22 user_id: UserId,
23 }
24
25 #[derive(Serialize)]
26 struct CreatorProject {
27 id: ProjectId,
28 slug: String,
29 title: String,
30 project_type: ProjectType,
31 is_public: bool,
32 item_count: i64,
33 revenue_cents: i64,
34 }
35
36 /// GET /api/internal/creator/projects?user_id={uuid}
37 ///
38 /// List all projects for a creator with item counts and revenue.
39 #[tracing::instrument(skip_all, name = "internal::creator_projects")]
40 pub(super) async fn creator_projects(
41 State(state): State<AppState>,
42 _auth: ServiceAuth,
43 Query(query): Query<UserIdQuery>,
44 ) -> Result<impl IntoResponse> {
45 let projects = db::projects::get_projects_by_user(&state.db, query.user_id).await?;
46 let revenue = db::transactions::get_revenue_by_user_projects(&state.db, query.user_id).await?;
47
48 // Build revenue lookup: project_id -> cents
49 let revenue_map: std::collections::HashMap<ProjectId, i64> = revenue
50 .into_iter()
51 .map(|(pid, _title, cents)| (pid, cents))
52 .collect();
53
54 // Count items per project in a single query
55 let item_counts = db::items::count_items_by_user_projects(&state.db, query.user_id).await?;
56 let count_map: std::collections::HashMap<ProjectId, i64> = item_counts.into_iter().collect();
57
58 let data: Vec<CreatorProject> = projects
59 .into_iter()
60 .map(|p| CreatorProject {
61 id: p.id,
62 slug: p.slug.to_string(),
63 title: p.title,
64 project_type: p.project_type,
65 is_public: p.is_public,
66 item_count: count_map.get(&p.id).copied().unwrap_or(0),
67 revenue_cents: revenue_map.get(&p.id).copied().unwrap_or(0),
68 })
69 .collect();
70
71 Ok(Json(data))
72 }
73
74 // ── Project items ──
75
76 #[derive(Serialize)]
77 struct CreatorItem {
78 id: ItemId,
79 title: String,
80 item_type: ItemType,
81 price_cents: i32,
82 is_public: bool,
83 sort_order: i32,
84 }
85
86 /// GET /api/internal/creator/projects/{id}/items?user_id={uuid}
87 ///
88 /// List items in a project (verifies ownership).
89 #[tracing::instrument(skip_all, name = "internal::creator_project_items")]
90 pub(super) async fn creator_project_items(
91 State(state): State<AppState>,
92 _auth: ServiceAuth,
93 Path(project_id): Path<ProjectId>,
94 Query(query): Query<UserIdQuery>,
95 ) -> Result<impl IntoResponse> {
96 // Verify ownership
97 let project = db::projects::get_project_by_id(&state.db, project_id)
98 .await?
99 .ok_or(AppError::NotFound)?;
100 if project.user_id != query.user_id {
101 return Err(AppError::Forbidden);
102 }
103
104 let items = db::items::get_items_by_project(&state.db, project_id).await?;
105
106 let data: Vec<CreatorItem> = items
107 .into_iter()
108 .map(|i| CreatorItem {
109 id: i.id,
110 title: i.title,
111 item_type: i.item_type,
112 price_cents: i.price_cents,
113 is_public: i.is_public,
114 sort_order: i.sort_order,
115 })
116 .collect();
117
118 Ok(Json(data))
119 }
120
121 // ── Creator stats ──
122
123 #[derive(Deserialize)]
124 pub(super) struct StatsQuery {
125 user_id: UserId,
126 /// Time range: "7d", "30d", "90d", or "all"
127 #[serde(default = "default_range")]
128 range: String,
129 }
130
131 fn default_range() -> String {
132 "30d".to_string()
133 }
134
135 #[derive(Serialize)]
136 struct CreatorStats {
137 current_revenue_cents: i64,
138 previous_revenue_cents: i64,
139 current_sales: i64,
140 previous_sales: i64,
141 current_followers: i64,
142 previous_followers: i64,
143 total_projects: i64,
144 total_items: i64,
145 }
146
147 /// GET /api/internal/creator/stats?user_id={uuid}&range=30d
148 ///
149 /// Period comparison stats for the creator dashboard.
150 #[tracing::instrument(skip_all, name = "internal::creator_stats")]
151 pub(super) async fn creator_stats(
152 State(state): State<AppState>,
153 _auth: ServiceAuth,
154 Query(query): Query<StatsQuery>,
155 ) -> Result<impl IntoResponse> {
156 let range: db::analytics::TimeRange = query
157 .range
158 .parse()
159 .map_err(|_| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?;
160
161 let comparison =
162 db::analytics::get_period_comparison(&state.db, query.user_id, None, None, &range).await?;
163
164 let projects = db::projects::get_projects_by_user(&state.db, query.user_id).await?;
165 let items = db::items::get_items_by_user(&state.db, query.user_id).await?;
166
167 Ok(Json(CreatorStats {
168 current_revenue_cents: comparison.current_revenue_cents.as_i64(),
169 previous_revenue_cents: comparison.previous_revenue_cents.as_i64(),
170 current_sales: comparison.current_sales,
171 previous_sales: comparison.previous_sales,
172 current_followers: comparison.current_followers,
173 previous_followers: comparison.previous_followers,
174 total_projects: projects.len() as i64,
175 total_items: items.len() as i64,
176 }))
177 }
178
179 // ── Analytics ──
180
181 #[derive(Serialize)]
182 struct AnalyticsBucket {
183 label: String,
184 revenue_cents: i64,
185 sales_count: i64,
186 }
187
188 #[derive(Serialize)]
189 struct ProjectRevenueSummary {
190 id: ProjectId,
191 title: String,
192 revenue_cents: i64,
193 }
194
195 #[derive(Serialize)]
196 struct AnalyticsResponse {
197 buckets: Vec<AnalyticsBucket>,
198 current_revenue_cents: i64,
199 previous_revenue_cents: i64,
200 current_sales: i64,
201 previous_sales: i64,
202 current_followers: i64,
203 previous_followers: i64,
204 top_projects: Vec<ProjectRevenueSummary>,
205 }
206
207 /// GET /api/internal/creator/analytics?user_id={uuid}&range=30d
208 ///
209 /// Revenue timeseries, period comparison, and top projects.
210 #[tracing::instrument(skip_all, name = "internal::creator_analytics")]
211 pub(super) async fn creator_analytics(
212 State(state): State<AppState>,
213 _auth: ServiceAuth,
214 Query(query): Query<StatsQuery>,
215 ) -> Result<impl IntoResponse> {
216 let range: db::analytics::TimeRange = query
217 .range
218 .parse()
219 .map_err(|_| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?;
220
221 let buckets =
222 db::analytics::get_revenue_timeseries(&state.db, query.user_id, None, None, &range)
223 .await?;
224 let comparison =
225 db::analytics::get_period_comparison(&state.db, query.user_id, None, None, &range).await?;
226 let revenue =
227 db::transactions::get_revenue_by_user_projects(&state.db, query.user_id).await?;
228
229 let top_projects: Vec<ProjectRevenueSummary> = revenue
230 .into_iter()
231 .map(|(id, title, cents)| ProjectRevenueSummary {
232 id,
233 title,
234 revenue_cents: cents,
235 })
236 .collect();
237
238 Ok(Json(AnalyticsResponse {
239 buckets: buckets
240 .into_iter()
241 .map(|b| AnalyticsBucket {
242 label: b.label,
243 revenue_cents: b.revenue_cents.as_i64(),
244 sales_count: b.sales_count,
245 })
246 .collect(),
247 current_revenue_cents: comparison.current_revenue_cents.as_i64(),
248 previous_revenue_cents: comparison.previous_revenue_cents.as_i64(),
249 current_sales: comparison.current_sales,
250 previous_sales: comparison.previous_sales,
251 current_followers: comparison.current_followers,
252 previous_followers: comparison.previous_followers,
253 top_projects,
254 }))
255 }
256
257 // ── Transactions ──
258
259 #[derive(Serialize)]
260 struct TransactionResponse {
261 id: TransactionId,
262 item_title: Option<String>,
263 amount_cents: i32,
264 status: String,
265 created_at: String,
266 completed_at: Option<String>,
267 }
268
269 /// GET /api/internal/creator/transactions?user_id={uuid}
270 ///
271 /// Recent seller transactions (up to 100).
272 #[tracing::instrument(skip_all, name = "internal::creator_transactions")]
273 pub(super) async fn creator_transactions(
274 State(state): State<AppState>,
275 _auth: ServiceAuth,
276 Query(query): Query<UserIdQuery>,
277 ) -> Result<impl IntoResponse> {
278 let txs =
279 db::transactions::get_transactions_by_seller(&state.db, query.user_id, Some(100)).await?;
280
281 let data: Vec<TransactionResponse> = txs
282 .into_iter()
283 .map(|t| TransactionResponse {
284 id: t.id,
285 item_title: t.item_title,
286 amount_cents: t.amount_cents.as_i64() as i32,
287 status: t.status.to_string(),
288 created_at: t.created_at.to_rfc3339(),
289 completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
290 })
291 .collect();
292
293 Ok(Json(data))
294 }
295
296 // ── Export sales CSV ──
297
298 /// GET /api/internal/creator/export/sales?user_id={uuid}
299 ///
300 /// Returns sales data as a CSV string.
301 #[tracing::instrument(skip_all, name = "internal::export_sales")]
302 pub(super) async fn export_sales(
303 State(state): State<AppState>,
304 _auth: ServiceAuth,
305 Query(query): Query<UserIdQuery>,
306 ) -> Result<impl IntoResponse> {
307 let rows =
308 db::transactions::get_seller_transactions_for_export(&state.db, query.user_id).await?;
309
310 let mut csv = String::from("Date,Item ID,Item Title,Amount,Status,Buyer Email\n");
311 for tx in &rows {
312 let date = tx.created_at.format("%Y-%m-%d %H:%M:%S").to_string();
313 let item_id = tx
314 .item_id
315 .map(|id| id.to_string())
316 .unwrap_or_default();
317 let item_title = tx.item_title.as_deref().unwrap_or("");
318 let amount = format!("{}.{:02}", tx.amount_cents / 100, tx.amount_cents.abs() % 100);
319 let status = tx.status.to_string();
320 let email = tx.buyer_email.as_deref().unwrap_or("");
321
322 csv.push_str(&format!(
323 "{},{},{},{},{},{}\n",
324 helpers::sanitize_csv_cell(&date),
325 helpers::sanitize_csv_cell(&item_id),
326 helpers::sanitize_csv_cell(item_title),
327 amount,
328 helpers::sanitize_csv_cell(&status),
329 helpers::sanitize_csv_cell(email),
330 ));
331 }
332
333 Ok(Json(serde_json::json!({ "csv": csv, "row_count": rows.len() })))
334 }
335