Skip to main content

max / makenotwork

33.0 KB · 1096 lines History Blame Raw
1 //! HTTP client for the MNW internal API.
2
3 use serde::{Deserialize, Serialize};
4
5 /// User info returned from the SSH key lookup endpoint.
6 #[derive(Debug, Clone, Deserialize, Serialize)]
7 pub struct UserInfo {
8 pub user_id: String,
9 pub username: String,
10 pub display_name: Option<String>,
11 pub creator_tier: Option<String>,
12 pub can_create_projects: bool,
13 pub suspended: bool,
14 }
15
16 /// A creator's project with item count and revenue.
17 #[derive(Debug, Clone, Deserialize, Serialize)]
18 pub struct Project {
19 pub id: String,
20 pub slug: String,
21 pub title: String,
22 pub project_type: String,
23 pub is_public: bool,
24 pub item_count: i64,
25 pub revenue_cents: i64,
26 }
27
28 /// An item within a project.
29 #[derive(Debug, Clone, Deserialize, Serialize)]
30 pub struct Item {
31 pub id: String,
32 pub title: String,
33 pub item_type: String,
34 pub price_cents: i32,
35 pub is_public: bool,
36 pub sort_order: i32,
37 }
38
39 /// Period comparison stats for the creator.
40 #[derive(Debug, Clone, Deserialize, Serialize)]
41 pub struct CreatorStats {
42 pub current_revenue_cents: i64,
43 pub previous_revenue_cents: i64,
44 pub current_sales: i64,
45 pub previous_sales: i64,
46 pub current_followers: i64,
47 pub previous_followers: i64,
48 pub total_projects: i64,
49 pub total_items: i64,
50 }
51
52 /// Response from the create-item internal endpoint.
53 #[derive(Debug, Deserialize)]
54 #[allow(dead_code)]
55 pub struct ItemCreated {
56 pub item_id: String,
57 pub project_id: String,
58 }
59
60 /// Response from the presign-upload internal endpoint.
61 #[derive(Debug, Deserialize)]
62 #[allow(dead_code)]
63 pub struct PresignResponse {
64 pub upload_url: String,
65 pub s3_key: String,
66 pub expires_in: u64,
67 pub cache_control: Option<String>,
68 }
69
70 /// Full item detail returned from the get/update endpoints.
71 #[derive(Debug, Clone, Deserialize, Serialize)]
72 pub struct ItemDetail {
73 pub id: String,
74 pub title: String,
75 pub description: Option<String>,
76 pub price_cents: i32,
77 pub item_type: String,
78 pub is_public: bool,
79 pub slug: String,
80 pub sort_order: i32,
81 pub sales_count: i32,
82 pub download_count: i32,
83 pub play_count: i32,
84 pub pwyw_enabled: bool,
85 pub pwyw_min_cents: Option<i32>,
86 pub has_audio: bool,
87 pub has_cover: bool,
88 pub created_at: String,
89 pub updated_at: String,
90 }
91
92 /// A version of an item.
93 #[derive(Debug, Clone, Deserialize, Serialize)]
94 pub struct Version {
95 pub id: String,
96 pub version_number: String,
97 pub changelog: Option<String>,
98 pub file_name: Option<String>,
99 pub file_size_bytes: Option<i64>,
100 pub download_count: i32,
101 pub is_current: bool,
102 pub created_at: String,
103 }
104
105 /// A blog post summary.
106 #[derive(Debug, Clone, Deserialize, Serialize)]
107 pub struct BlogPost {
108 pub id: String,
109 pub title: String,
110 pub slug: String,
111 pub is_published: bool,
112 pub publish_at: Option<String>,
113 pub created_at: String,
114 pub updated_at: String,
115 }
116
117 /// A promo code.
118 #[derive(Debug, Clone, Deserialize, Serialize)]
119 pub struct PromoCode {
120 pub id: String,
121 pub code: String,
122 pub code_purpose: String,
123 pub discount_type: Option<String>,
124 pub discount_value: Option<i32>,
125 pub item_title: Option<String>,
126 pub project_title: Option<String>,
127 pub max_uses: Option<i32>,
128 pub use_count: i32,
129 pub created_at: String,
130 }
131
132 /// A license key.
133 #[derive(Debug, Clone, Deserialize, Serialize)]
134 pub struct LicenseKey {
135 pub id: String,
136 pub key_code: String,
137 pub activation_count: i32,
138 pub max_activations: Option<i32>,
139 pub is_revoked: bool,
140 pub created_at: String,
141 }
142
143 /// Response from the storage-info internal endpoint.
144 #[derive(Debug, Clone, Deserialize, Serialize)]
145 pub struct StorageInfo {
146 pub storage_used_bytes: i64,
147 pub max_storage_bytes: i64,
148 pub allows_file_uploads: bool,
149 }
150
151 /// A revenue bucket for analytics timeseries.
152 #[derive(Debug, Clone, Deserialize, Serialize)]
153 pub struct AnalyticsBucket {
154 pub label: String,
155 pub revenue_cents: i64,
156 pub sales_count: i64,
157 }
158
159 /// Per-project revenue summary.
160 #[derive(Debug, Clone, Deserialize, Serialize)]
161 pub struct ProjectRevenue {
162 pub id: String,
163 pub title: String,
164 pub revenue_cents: i64,
165 }
166
167 /// Analytics response with timeseries, comparison, and top projects.
168 #[derive(Debug, Clone, Deserialize, Serialize)]
169 pub struct AnalyticsData {
170 pub buckets: Vec<AnalyticsBucket>,
171 pub current_revenue_cents: i64,
172 pub previous_revenue_cents: i64,
173 pub current_sales: i64,
174 pub previous_sales: i64,
175 pub current_followers: i64,
176 pub previous_followers: i64,
177 pub top_projects: Vec<ProjectRevenue>,
178 }
179
180 /// A seller transaction.
181 #[derive(Debug, Clone, Deserialize, Serialize)]
182 pub struct Transaction {
183 pub id: String,
184 pub item_title: Option<String>,
185 pub amount_cents: i32,
186 pub status: String,
187 pub created_at: String,
188 pub completed_at: Option<String>,
189 }
190
191 /// CSV export result.
192 #[derive(Debug, Clone, Deserialize, Serialize)]
193 pub struct ExportResult {
194 pub csv: String,
195 pub row_count: usize,
196 }
197
198 /// A registered SSH key.
199 #[derive(Debug, Clone, Deserialize, Serialize)]
200 pub struct SshKeyInfo {
201 pub id: String,
202 pub label: String,
203 pub fingerprint: String,
204 pub created_at: String,
205 }
206
207 /// A tag on an item or from search.
208 #[derive(Debug, Clone, Deserialize, Serialize)]
209 pub struct TagInfo {
210 pub id: String,
211 pub name: String,
212 pub slug: String,
213 pub is_primary: bool,
214 }
215
216 /// Result of a broadcast send.
217 #[derive(Debug, Deserialize)]
218 pub struct BroadcastResult {
219 pub success: bool,
220 pub recipient_count: usize,
221 }
222
223 /// A subscription tier.
224 #[derive(Debug, Clone, Deserialize, Serialize)]
225 pub struct TierInfo {
226 pub id: String,
227 pub name: String,
228 pub description: String,
229 pub price_cents: i32,
230 pub is_active: bool,
231 }
232
233 /// A collection.
234 #[derive(Debug, Clone, Deserialize, Serialize)]
235 pub struct CollectionInfo {
236 pub id: String,
237 pub slug: String,
238 pub title: String,
239 pub description: String,
240 pub is_public: bool,
241 pub item_count: i64,
242 }
243
244 /// Custom domain info.
245 #[derive(Debug, Clone, Deserialize, Serialize)]
246 pub struct DomainInfo {
247 pub id: String,
248 pub domain: String,
249 pub verified: bool,
250 pub verification_token: String,
251 pub instructions: Option<String>,
252 }
253
254 /// Domain verification result.
255 #[derive(Debug, Deserialize)]
256 pub struct DomainVerifyResult {
257 pub verified: bool,
258 pub message: String,
259 }
260
261 /// Response from the git authorize endpoint.
262 #[derive(Debug, Deserialize)]
263 pub struct GitAuthResponse {
264 pub repo_path: String,
265 }
266
267 /// Check response status and deserialize JSON body, or bail with error details.
268 async fn json_response<T: serde::de::DeserializeOwned>(
269 resp: reqwest::Response,
270 context: &str,
271 ) -> anyhow::Result<T> {
272 if !resp.status().is_success() {
273 let status = resp.status();
274 let body = resp.text().await.unwrap_or_else(|e| {
275 tracing::warn!(error = %e, %context, "failed to read error response body");
276 String::new()
277 });
278 if body.is_empty() {
279 anyhow::bail!("{context} failed: HTTP {status}");
280 }
281 anyhow::bail!("{context} failed: HTTP {status} — {body}");
282 }
283 Ok(resp.json().await?)
284 }
285
286 /// Check response status for success, or bail with error details.
287 async fn empty_response(resp: reqwest::Response, context: &str) -> anyhow::Result<()> {
288 if !resp.status().is_success() {
289 let status = resp.status();
290 let body = resp.text().await.unwrap_or_else(|e| {
291 tracing::warn!(error = %e, %context, "failed to read error response body");
292 String::new()
293 });
294 if body.is_empty() {
295 anyhow::bail!("{context} failed: HTTP {status}");
296 }
297 anyhow::bail!("{context} failed: HTTP {status} — {body}");
298 }
299 Ok(())
300 }
301
302 /// Client for calling MNW internal API endpoints.
303 #[derive(Clone)]
304 pub struct MnwApiClient {
305 http: reqwest::Client,
306 base_url: String,
307 service_token: String,
308 }
309
310 impl MnwApiClient {
311 pub fn new(base_url: String, service_token: String) -> Self {
312 let http = reqwest::Client::builder()
313 .timeout(std::time::Duration::from_secs(5))
314 .build()
315 .expect("failed to build HTTP client");
316
317 Self {
318 http,
319 base_url,
320 service_token,
321 }
322 }
323
324 /// Look up a user by SSH key fingerprint.
325 /// Returns `Ok(Some(info))` if found, `Ok(None)` if not found.
326 pub async fn lookup_ssh_key(&self, fingerprint: &str) -> anyhow::Result<Option<UserInfo>> {
327 let url = format!("{}/api/internal/ssh-key-lookup", self.base_url);
328 let resp = self
329 .http
330 .get(&url)
331 .bearer_auth(&self.service_token)
332 .query(&[("fingerprint", fingerprint)])
333 .send()
334 .await?;
335
336 if resp.status() == reqwest::StatusCode::NOT_FOUND {
337 return Ok(None);
338 }
339
340 if !resp.status().is_success() {
341 anyhow::bail!("SSH key lookup failed: HTTP {}", resp.status());
342 }
343
344 let info: UserInfo = resp.json().await?;
345 Ok(Some(info))
346 }
347
348 /// Fetch all projects for a creator with item counts and revenue.
349 pub async fn get_projects(&self, user_id: &str) -> anyhow::Result<Vec<Project>> {
350 let url = format!("{}/api/internal/creator/projects", self.base_url);
351 let resp = self
352 .http
353 .get(&url)
354 .bearer_auth(&self.service_token)
355 .query(&[("user_id", user_id)])
356 .send()
357 .await?;
358
359 json_response(resp, "get_projects").await
360 }
361
362 /// Create a new project.
363 pub async fn create_project(
364 &self,
365 user_id: &str,
366 title: &str,
367 project_type: &str,
368 description: Option<&str>,
369 ) -> anyhow::Result<Project> {
370 let url = format!("{}/api/internal/creator/projects", self.base_url);
371 let mut body = serde_json::json!({
372 "user_id": user_id,
373 "title": title,
374 "project_type": project_type,
375 });
376 if let Some(desc) = description {
377 body["description"] = serde_json::Value::String(desc.to_string());
378 }
379 let resp = self
380 .http
381 .post(&url)
382 .bearer_auth(&self.service_token)
383 .json(&body)
384 .send()
385 .await?;
386
387 json_response(resp, "create_project").await
388 }
389
390 /// Fetch items in a project.
391 pub async fn get_project_items(
392 &self,
393 project_id: &str,
394 user_id: &str,
395 ) -> anyhow::Result<Vec<Item>> {
396 let url = format!(
397 "{}/api/internal/creator/projects/{}/items",
398 self.base_url, project_id
399 );
400 let resp = self
401 .http
402 .get(&url)
403 .bearer_auth(&self.service_token)
404 .query(&[("user_id", user_id)])
405 .send()
406 .await?;
407
408 json_response(resp, "get_project_items").await
409 }
410
411 /// Fetch period comparison stats for a creator.
412 pub async fn get_stats(&self, user_id: &str, range: &str) -> anyhow::Result<CreatorStats> {
413 let url = format!("{}/api/internal/creator/stats", self.base_url);
414 let resp = self
415 .http
416 .get(&url)
417 .bearer_auth(&self.service_token)
418 .query(&[("user_id", user_id), ("range", range)])
419 .send()
420 .await?;
421
422 json_response(resp, "get_stats").await
423 }
424
425 /// Fetch storage usage and limits for a creator.
426 pub async fn get_storage_info(&self, user_id: &str) -> anyhow::Result<StorageInfo> {
427 let url = format!("{}/api/internal/creator/storage", self.base_url);
428 let resp = self
429 .http
430 .get(&url)
431 .bearer_auth(&self.service_token)
432 .query(&[("user_id", user_id)])
433 .send()
434 .await?;
435
436 json_response(resp, "get_storage_info").await
437 }
438
439 /// Create an item in a project.
440 pub async fn create_item(
441 &self,
442 user_id: &str,
443 project_id: &str,
444 title: &str,
445 item_type: &str,
446 price_cents: i32,
447 ) -> anyhow::Result<ItemCreated> {
448 let url = format!("{}/api/internal/creator/items", self.base_url);
449 let resp = self
450 .http
451 .post(&url)
452 .bearer_auth(&self.service_token)
453 .json(&serde_json::json!({
454 "user_id": user_id,
455 "project_id": project_id,
456 "title": title,
457 "item_type": item_type,
458 "price_cents": price_cents,
459 }))
460 .send()
461 .await?;
462
463 json_response(resp, "create_item").await
464 }
465
466 /// Get a presigned S3 upload URL.
467 pub async fn presign_upload(
468 &self,
469 user_id: &str,
470 item_id: &str,
471 file_type: &str,
472 file_name: &str,
473 content_type: &str,
474 ) -> anyhow::Result<PresignResponse> {
475 let url = format!("{}/api/internal/upload/presign", self.base_url);
476 let resp = self
477 .http
478 .post(&url)
479 .bearer_auth(&self.service_token)
480 .json(&serde_json::json!({
481 "user_id": user_id,
482 "item_id": item_id,
483 "file_type": file_type,
484 "file_name": file_name,
485 "content_type": content_type,
486 }))
487 .send()
488 .await?;
489
490 json_response(resp, "presign_upload").await
491 }
492
493 /// Confirm a completed S3 upload.
494 pub async fn confirm_upload(
495 &self,
496 user_id: &str,
497 item_id: &str,
498 file_type: &str,
499 s3_key: &str,
500 ) -> anyhow::Result<bool> {
501 let url = format!("{}/api/internal/upload/confirm", self.base_url);
502 let resp = self
503 .http
504 .post(&url)
505 .bearer_auth(&self.service_token)
506 .json(&serde_json::json!({
507 "user_id": user_id,
508 "item_id": item_id,
509 "file_type": file_type,
510 "s3_key": s3_key,
511 }))
512 .send()
513 .await?;
514
515 #[derive(Deserialize)]
516 struct Resp {
517 success: bool,
518 }
519 let r: Resp = json_response(resp, "confirm_upload").await?;
520 Ok(r.success)
521 }
522
523 /// Fetch full item detail.
524 pub async fn get_item_detail(
525 &self,
526 user_id: &str,
527 item_id: &str,
528 ) -> anyhow::Result<ItemDetail> {
529 let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
530 let resp = self
531 .http
532 .get(&url)
533 .bearer_auth(&self.service_token)
534 .query(&[("user_id", user_id)])
535 .send()
536 .await?;
537
538 json_response(resp, "get_item_detail").await
539 }
540
541 /// Update item fields. Only non-None fields are changed.
542 pub async fn update_item(
543 &self,
544 user_id: &str,
545 item_id: &str,
546 title: Option<&str>,
547 description: Option<&str>,
548 price_cents: Option<i32>,
549 is_public: Option<bool>,
550 ) -> anyhow::Result<ItemDetail> {
551 let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
552 let mut body = serde_json::json!({ "user_id": user_id });
553 if let Some(t) = title {
554 body["title"] = serde_json::Value::String(t.to_string());
555 }
556 if let Some(d) = description {
557 body["description"] = serde_json::Value::String(d.to_string());
558 }
559 if let Some(p) = price_cents {
560 body["price_cents"] = serde_json::json!(p);
561 }
562 if let Some(v) = is_public {
563 body["is_public"] = serde_json::json!(v);
564 }
565
566 let resp = self
567 .http
568 .put(&url)
569 .bearer_auth(&self.service_token)
570 .json(&body)
571 .send()
572 .await?;
573
574 json_response(resp, "update_item").await
575 }
576
577 /// Delete an item permanently.
578 pub async fn delete_item(&self, user_id: &str, item_id: &str) -> anyhow::Result<()> {
579 let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
580 let resp = self
581 .http
582 .delete(&url)
583 .bearer_auth(&self.service_token)
584 .query(&[("user_id", user_id)])
585 .send()
586 .await?;
587
588 empty_response(resp, "delete_item").await
589 }
590
591 /// Publish an item (set is_public=true).
592 pub async fn publish_item(
593 &self,
594 user_id: &str,
595 item_id: &str,
596 ) -> anyhow::Result<ItemDetail> {
597 let url = format!(
598 "{}/api/internal/creator/items/{}/publish",
599 self.base_url, item_id
600 );
601 let resp = self
602 .http
603 .post(&url)
604 .bearer_auth(&self.service_token)
605 .json(&serde_json::json!({ "user_id": user_id }))
606 .send()
607 .await?;
608
609 json_response(resp, "publish_item").await
610 }
611
612 /// Unpublish an item (set is_public=false).
613 pub async fn unpublish_item(
614 &self,
615 user_id: &str,
616 item_id: &str,
617 ) -> anyhow::Result<ItemDetail> {
618 let url = format!(
619 "{}/api/internal/creator/items/{}/unpublish",
620 self.base_url, item_id
621 );
622 let resp = self
623 .http
624 .post(&url)
625 .bearer_auth(&self.service_token)
626 .json(&serde_json::json!({ "user_id": user_id }))
627 .send()
628 .await?;
629
630 json_response(resp, "unpublish_item").await
631 }
632
633 /// Fetch versions for an item.
634 pub async fn get_item_versions(
635 &self,
636 user_id: &str,
637 item_id: &str,
638 ) -> anyhow::Result<Vec<Version>> {
639 let url = format!(
640 "{}/api/internal/creator/items/{}/versions",
641 self.base_url, item_id
642 );
643 let resp = self
644 .http
645 .get(&url)
646 .bearer_auth(&self.service_token)
647 .query(&[("user_id", user_id)])
648 .send()
649 .await?;
650
651 json_response(resp, "get_item_versions").await
652 }
653
654 /// Upload a file to S3 using a presigned URL.
655 pub async fn upload_to_s3(
656 &self,
657 presigned_url: &str,
658 file_path: &std::path::Path,
659 content_type: &str,
660 cache_control: Option<&str>,
661 ) -> anyhow::Result<()> {
662 let data = tokio::fs::read(file_path).await?;
663 let mut req = self
664 .http
665 .put(presigned_url)
666 .header("content-type", content_type)
667 .body(data);
668
669 if let Some(cc) = cache_control {
670 req = req.header("cache-control", cc);
671 }
672
673 let resp = req.send().await?;
674
675 if !resp.status().is_success() {
676 anyhow::bail!("S3 upload failed: HTTP {}", resp.status());
677 }
678
679 Ok(())
680 }
681
682 // ── Blog posts ──
683
684 /// List blog posts for a project.
685 pub async fn list_blog_posts(
686 &self,
687 user_id: &str,
688 project_id: &str,
689 ) -> anyhow::Result<Vec<BlogPost>> {
690 let url = format!(
691 "{}/api/internal/creator/projects/{}/blog",
692 self.base_url, project_id
693 );
694 let resp = self
695 .http
696 .get(&url)
697 .bearer_auth(&self.service_token)
698 .query(&[("user_id", user_id)])
699 .send()
700 .await?;
701
702 json_response(resp, "list_blog_posts").await
703 }
704
705 /// Create a blog post, optionally scheduled for future publication.
706 pub async fn create_blog_post(
707 &self,
708 user_id: &str,
709 project_id: &str,
710 title: &str,
711 body_markdown: &str,
712 publish: bool,
713 publish_at: Option<&str>,
714 ) -> anyhow::Result<BlogPost> {
715 let url = format!("{}/api/internal/creator/blog", self.base_url);
716 let mut body = serde_json::json!({
717 "user_id": user_id,
718 "project_id": project_id,
719 "title": title,
720 "body_markdown": body_markdown,
721 "publish": publish,
722 });
723 if let Some(pa) = publish_at {
724 body["publish_at"] = serde_json::Value::String(pa.to_string());
725 }
726 let resp = self
727 .http
728 .post(&url)
729 .bearer_auth(&self.service_token)
730 .json(&body)
731 .send()
732 .await?;
733
734 json_response(resp, "create_blog_post").await
735 }
736
737 /// Delete a blog post.
738 pub async fn delete_blog_post(&self, user_id: &str, post_id: &str) -> anyhow::Result<()> {
739 let url = format!("{}/api/internal/creator/blog/{}", self.base_url, post_id);
740 let resp = self
741 .http
742 .delete(&url)
743 .bearer_auth(&self.service_token)
744 .query(&[("user_id", user_id)])
745 .send()
746 .await?;
747
748 empty_response(resp, "delete_blog_post").await
749 }
750
751 // ── Promo codes ──
752
753 /// List promo codes for a creator.
754 pub async fn list_promo_codes(&self, user_id: &str) -> anyhow::Result<Vec<PromoCode>> {
755 let url = format!("{}/api/internal/creator/promo-codes", self.base_url);
756 let resp = self
757 .http
758 .get(&url)
759 .bearer_auth(&self.service_token)
760 .query(&[("user_id", user_id)])
761 .send()
762 .await?;
763
764 json_response(resp, "list_promo_codes").await
765 }
766
767 /// Create a promo code.
768 pub async fn create_promo_code(
769 &self,
770 user_id: &str,
771 code: &str,
772 discount_type: &str,
773 discount_value: i32,
774 max_uses: Option<i32>,
775 project_id: Option<&str>,
776 ) -> anyhow::Result<PromoCode> {
777 let url = format!("{}/api/internal/creator/promo-codes", self.base_url);
778 let mut body = serde_json::json!({
779 "user_id": user_id,
780 "code": code,
781 "code_purpose": "discount",
782 "discount_type": discount_type,
783 "discount_value": discount_value,
784 });
785 if let Some(max) = max_uses {
786 body["max_uses"] = serde_json::json!(max);
787 }
788 if let Some(pid) = project_id {
789 body["project_id"] = serde_json::json!(pid);
790 }
791
792 let resp = self
793 .http
794 .post(&url)
795 .bearer_auth(&self.service_token)
796 .json(&body)
797 .send()
798 .await?;
799
800 json_response(resp, "create_promo_code").await
801 }
802
803 /// Delete a promo code.
804 pub async fn delete_promo_code(&self, user_id: &str, code_id: &str) -> anyhow::Result<()> {
805 let url = format!(
806 "{}/api/internal/creator/promo-codes/{}",
807 self.base_url, code_id
808 );
809 let resp = self
810 .http
811 .delete(&url)
812 .bearer_auth(&self.service_token)
813 .query(&[("user_id", user_id)])
814 .send()
815 .await?;
816
817 empty_response(resp, "delete_promo_code").await
818 }
819
820 // ── License keys ──
821
822 /// List license keys for an item.
823 pub async fn list_license_keys(
824 &self,
825 user_id: &str,
826 item_id: &str,
827 ) -> anyhow::Result<Vec<LicenseKey>> {
828 let url = format!(
829 "{}/api/internal/creator/items/{}/keys",
830 self.base_url, item_id
831 );
832 let resp = self
833 .http
834 .get(&url)
835 .bearer_auth(&self.service_token)
836 .query(&[("user_id", user_id)])
837 .send()
838 .await?;
839
840 json_response(resp, "list_license_keys").await
841 }
842
843 /// Generate a new license key for an item.
844 pub async fn generate_license_key(
845 &self,
846 user_id: &str,
847 item_id: &str,
848 ) -> anyhow::Result<LicenseKey> {
849 let url = format!(
850 "{}/api/internal/creator/items/{}/keys",
851 self.base_url, item_id
852 );
853 let resp = self
854 .http
855 .post(&url)
856 .bearer_auth(&self.service_token)
857 .json(&serde_json::json!({ "user_id": user_id }))
858 .send()
859 .await?;
860
861 json_response(resp, "generate_license_key").await
862 }
863
864 /// Revoke a license key.
865 pub async fn revoke_license_key(
866 &self,
867 user_id: &str,
868 key_id: &str,
869 ) -> anyhow::Result<()> {
870 let url = format!(
871 "{}/api/internal/creator/keys/{}/revoke",
872 self.base_url, key_id
873 );
874 let resp = self
875 .http
876 .post(&url)
877 .bearer_auth(&self.service_token)
878 .json(&serde_json::json!({ "user_id": user_id }))
879 .send()
880 .await?;
881
882 empty_response(resp, "revoke_license_key").await
883 }
884
885 // ── Analytics ──
886
887 /// Get analytics data (timeseries, period comparison, top projects).
888 pub async fn get_analytics(
889 &self,
890 user_id: &str,
891 range: &str,
892 ) -> anyhow::Result<AnalyticsData> {
893 let url = format!("{}/api/internal/creator/analytics", self.base_url);
894 let resp = self
895 .http
896 .get(&url)
897 .bearer_auth(&self.service_token)
898 .query(&[("user_id", user_id), ("range", range)])
899 .send()
900 .await?;
901
902 json_response(resp, "get_analytics").await
903 }
904
905 /// Get recent seller transactions.
906 pub async fn get_transactions(&self, user_id: &str) -> anyhow::Result<Vec<Transaction>> {
907 let url = format!("{}/api/internal/creator/transactions", self.base_url);
908 let resp = self
909 .http
910 .get(&url)
911 .bearer_auth(&self.service_token)
912 .query(&[("user_id", user_id)])
913 .send()
914 .await?;
915
916 json_response(resp, "get_transactions").await
917 }
918
919 /// Export sales as CSV string.
920 pub async fn export_sales_csv(&self, user_id: &str) -> anyhow::Result<ExportResult> {
921 let url = format!("{}/api/internal/creator/export/sales", self.base_url);
922 let resp = self
923 .http
924 .get(&url)
925 .bearer_auth(&self.service_token)
926 .query(&[("user_id", user_id)])
927 .send()
928 .await?;
929
930 json_response(resp, "export_sales_csv").await
931 }
932
933 // ── SSH keys ──
934
935 /// Authorize a git operation and get the on-disk repo path.
936 pub async fn git_authorize(
937 &self,
938 user_id: &str,
939 operation: &str,
940 owner: &str,
941 repo_name: &str,
942 ) -> anyhow::Result<GitAuthResponse> {
943 let url = format!("{}/api/internal/git/authorize", self.base_url);
944 let resp = self
945 .http
946 .post(&url)
947 .bearer_auth(&self.service_token)
948 .json(&serde_json::json!({
949 "user_id": user_id,
950 "operation": operation,
951 "owner": owner,
952 "repo_name": repo_name,
953 }))
954 .send()
955 .await?;
956
957 if !resp.status().is_success() {
958 let status = resp.status();
959 let body = resp.text().await.unwrap_or_else(|e| {
960 tracing::warn!(error = %e, "failed to read git_authorize error body");
961 String::new()
962 });
963 // Parse JSON error if available, fall back to status text
964 let msg = serde_json::from_str::<serde_json::Value>(&body)
965 .ok()
966 .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
967 .unwrap_or_else(|| format!("HTTP {status}"));
968 anyhow::bail!("{msg}");
969 }
970
971 Ok(resp.json().await?)
972 }
973
974 /// List registered SSH keys for a user.
975 pub async fn list_ssh_keys(&self, user_id: &str) -> anyhow::Result<Vec<SshKeyInfo>> {
976 let url = format!("{}/api/internal/creator/ssh-keys", self.base_url);
977 let resp = self
978 .http
979 .get(&url)
980 .bearer_auth(&self.service_token)
981 .query(&[("user_id", user_id)])
982 .send()
983 .await?;
984
985 json_response(resp, "list_ssh_keys").await
986 }
987
988 // ── Tags ──
989
990 pub async fn list_item_tags(&self, user_id: &str, item_id: &str) -> anyhow::Result<Vec<TagInfo>> {
991 let url = format!("{}/api/internal/creator/items/{}/tags", self.base_url, item_id);
992 let resp = self.http.get(&url).bearer_auth(&self.service_token)
993 .query(&[("user_id", user_id)]).send().await?;
994 json_response(resp, "list_item_tags").await
995 }
996
997 pub async fn search_tags(&self, query: &str) -> anyhow::Result<Vec<TagInfo>> {
998 let url = format!("{}/api/internal/tags/search", self.base_url);
999 let resp = self.http.get(&url).bearer_auth(&self.service_token)
1000 .query(&[("q", query)]).send().await?;
1001 json_response(resp, "search_tags").await
1002 }
1003
1004 pub async fn add_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> {
1005 let url = format!("{}/api/internal/creator/items/tags", self.base_url);
1006 let resp = self.http.post(&url).bearer_auth(&self.service_token)
1007 .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
1008 .send().await?;
1009 empty_response(resp, "add_item_tag").await
1010 }
1011
1012 pub async fn remove_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> {
1013 let url = format!("{}/api/internal/creator/items/tags/remove", self.base_url);
1014 let resp = self.http.post(&url).bearer_auth(&self.service_token)
1015 .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
1016 .send().await?;
1017 empty_response(resp, "remove_item_tag").await
1018 }
1019
1020 // ── Broadcast ──
1021
1022 pub async fn send_broadcast(&self, user_id: &str, subject: &str, body: &str) -> anyhow::Result<BroadcastResult> {
1023 let url = format!("{}/api/internal/creator/broadcast", self.base_url);
1024 let resp = self.http.post(&url).bearer_auth(&self.service_token)
1025 .json(&serde_json::json!({"user_id": user_id, "subject": subject, "body": body}))
1026 .send().await?;
1027 json_response(resp, "send_broadcast").await
1028 }
1029
1030 // ── Tiers ──
1031
1032 pub async fn list_tiers(&self, user_id: &str, project_id: &str) -> anyhow::Result<Vec<TierInfo>> {
1033 let url = format!("{}/api/internal/creator/projects/{}/tiers", self.base_url, project_id);
1034 let resp = self.http.get(&url).bearer_auth(&self.service_token)
1035 .query(&[("user_id", user_id)]).send().await?;
1036 json_response(resp, "list_tiers").await
1037 }
1038
1039 // ── Collections ──
1040
1041 pub async fn list_collections(&self, user_id: &str) -> anyhow::Result<Vec<CollectionInfo>> {
1042 let url = format!("{}/api/internal/creator/collections", self.base_url);
1043 let resp = self.http.get(&url).bearer_auth(&self.service_token)
1044 .query(&[("user_id", user_id)]).send().await?;
1045 json_response(resp, "list_collections").await
1046 }
1047
1048 pub async fn create_collection(&self, user_id: &str, slug: &str, title: &str) -> anyhow::Result<serde_json::Value> {
1049 let url = format!("{}/api/internal/creator/collections", self.base_url);
1050 let resp = self.http.post(&url).bearer_auth(&self.service_token)
1051 .json(&serde_json::json!({"user_id": user_id, "slug": slug, "title": title}))
1052 .send().await?;
1053 json_response(resp, "create_collection").await
1054 }
1055
1056 pub async fn delete_collection(&self, user_id: &str, collection_id: &str) -> anyhow::Result<()> {
1057 let url = format!("{}/api/internal/creator/collections/{}", self.base_url, collection_id);
1058 let resp = self.http.delete(&url).bearer_auth(&self.service_token)
1059 .query(&[("user_id", user_id)]).send().await?;
1060 empty_response(resp, "delete_collection").await
1061 }
1062
1063 // ── Custom Domains ──
1064
1065 pub async fn get_domain(&self, user_id: &str) -> anyhow::Result<Option<DomainInfo>> {
1066 let url = format!("{}/api/internal/creator/domain", self.base_url);
1067 let resp = self.http.get(&url).bearer_auth(&self.service_token)
1068 .query(&[("user_id", user_id)]).send().await?;
1069 let val: serde_json::Value = json_response(resp, "get_domain").await?;
1070 if val.is_null() { return Ok(None); }
1071 Ok(serde_json::from_value(val).ok())
1072 }
1073
1074 pub async fn add_domain(&self, user_id: &str, domain: &str) -> anyhow::Result<DomainInfo> {
1075 let url = format!("{}/api/internal/creator/domain", self.base_url);
1076 let resp = self.http.post(&url).bearer_auth(&self.service_token)
1077 .json(&serde_json::json!({"user_id": user_id, "domain": domain}))
1078 .send().await?;
1079 json_response(resp, "add_domain").await
1080 }
1081
1082 pub async fn verify_domain(&self, user_id: &str) -> anyhow::Result<DomainVerifyResult> {
1083 let url = format!("{}/api/internal/creator/domain/verify", self.base_url);
1084 let resp = self.http.post(&url).bearer_auth(&self.service_token)
1085 .query(&[("user_id", user_id)]).send().await?;
1086 json_response(resp, "verify_domain").await
1087 }
1088
1089 pub async fn remove_domain(&self, user_id: &str) -> anyhow::Result<()> {
1090 let url = format!("{}/api/internal/creator/domain", self.base_url);
1091 let resp = self.http.delete(&url).bearer_auth(&self.service_token)
1092 .query(&[("user_id", user_id)]).send().await?;
1093 empty_response(resp, "remove_domain").await
1094 }
1095 }
1096