//! HTTP client for the MNW internal API. use serde::{Deserialize, Serialize}; /// User info returned from the SSH key lookup endpoint. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct UserInfo { pub user_id: String, pub username: String, pub display_name: Option, pub creator_tier: Option, pub can_create_projects: bool, pub suspended: bool, } /// A creator's project with item count and revenue. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Project { pub id: String, pub slug: String, pub title: String, pub project_type: String, pub is_public: bool, pub item_count: i64, pub revenue_cents: i64, } /// An item within a project. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Item { pub id: String, pub title: String, pub item_type: String, pub price_cents: i32, pub is_public: bool, pub sort_order: i32, } /// Period comparison stats for the creator. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CreatorStats { pub current_revenue_cents: i64, pub previous_revenue_cents: i64, pub current_sales: i64, pub previous_sales: i64, pub current_followers: i64, pub previous_followers: i64, pub total_projects: i64, pub total_items: i64, } /// Response from the create-item internal endpoint. #[derive(Debug, Deserialize)] #[allow(dead_code)] pub struct ItemCreated { pub item_id: String, pub project_id: String, } /// Response from the presign-upload internal endpoint. #[derive(Debug, Deserialize)] #[allow(dead_code)] pub struct PresignResponse { pub upload_url: String, pub s3_key: String, pub expires_in: u64, pub cache_control: Option, } /// Full item detail returned from the get/update endpoints. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ItemDetail { pub id: String, pub title: String, pub description: Option, pub price_cents: i32, pub item_type: String, pub is_public: bool, pub slug: String, pub sort_order: i32, pub sales_count: i32, pub download_count: i32, pub play_count: i32, pub pwyw_enabled: bool, pub pwyw_min_cents: Option, pub has_audio: bool, pub has_cover: bool, pub created_at: String, pub updated_at: String, } /// A version of an item. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Version { pub id: String, pub version_number: String, pub changelog: Option, pub file_name: Option, pub file_size_bytes: Option, pub download_count: i32, pub is_current: bool, pub created_at: String, } /// A blog post summary. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BlogPost { pub id: String, pub title: String, pub slug: String, pub is_published: bool, pub created_at: String, pub updated_at: String, } /// A promo code. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct PromoCode { pub id: String, pub code: String, pub code_purpose: String, pub discount_type: Option, pub discount_value: Option, pub item_title: Option, pub project_title: Option, pub max_uses: Option, pub use_count: i32, pub created_at: String, } /// A license key. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct LicenseKey { pub id: String, pub key_code: String, pub activation_count: i32, pub max_activations: Option, pub is_revoked: bool, pub created_at: String, } /// Response from the storage-info internal endpoint. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct StorageInfo { pub storage_used_bytes: i64, pub max_storage_bytes: i64, pub allows_file_uploads: bool, } /// A revenue bucket for analytics timeseries. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AnalyticsBucket { pub label: String, pub revenue_cents: i64, pub sales_count: i64, } /// Per-project revenue summary. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ProjectRevenue { pub id: String, pub title: String, pub revenue_cents: i64, } /// Analytics response with timeseries, comparison, and top projects. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AnalyticsData { pub buckets: Vec, pub current_revenue_cents: i64, pub previous_revenue_cents: i64, pub current_sales: i64, pub previous_sales: i64, pub current_followers: i64, pub previous_followers: i64, pub top_projects: Vec, } /// A seller transaction. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Transaction { pub id: String, pub item_title: Option, pub amount_cents: i32, pub status: String, pub created_at: String, pub completed_at: Option, } /// CSV export result. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ExportResult { pub csv: String, pub row_count: usize, } /// A registered SSH key. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SshKeyInfo { pub id: String, pub label: String, pub fingerprint: String, pub created_at: String, } /// Response from the git authorize endpoint. #[derive(Debug, Deserialize)] pub struct GitAuthResponse { pub repo_path: String, } /// Check response status and deserialize JSON body, or bail with error details. async fn json_response( resp: reqwest::Response, context: &str, ) -> anyhow::Result { if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_else(|e| { tracing::warn!(error = %e, %context, "failed to read error response body"); String::new() }); if body.is_empty() { anyhow::bail!("{context} failed: HTTP {status}"); } anyhow::bail!("{context} failed: HTTP {status} — {body}"); } Ok(resp.json().await?) } /// Check response status for success, or bail with error details. async fn empty_response(resp: reqwest::Response, context: &str) -> anyhow::Result<()> { if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_else(|e| { tracing::warn!(error = %e, %context, "failed to read error response body"); String::new() }); if body.is_empty() { anyhow::bail!("{context} failed: HTTP {status}"); } anyhow::bail!("{context} failed: HTTP {status} — {body}"); } Ok(()) } /// Client for calling MNW internal API endpoints. #[derive(Clone)] pub struct MnwApiClient { http: reqwest::Client, base_url: String, service_token: String, } impl MnwApiClient { pub fn new(base_url: String, service_token: String) -> Self { let http = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) .build() .expect("failed to build HTTP client"); Self { http, base_url, service_token, } } /// Look up a user by SSH key fingerprint. /// Returns `Ok(Some(info))` if found, `Ok(None)` if not found. pub async fn lookup_ssh_key(&self, fingerprint: &str) -> anyhow::Result> { let url = format!("{}/api/internal/ssh-key-lookup", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("fingerprint", fingerprint)]) .send() .await?; if resp.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } if !resp.status().is_success() { anyhow::bail!("SSH key lookup failed: HTTP {}", resp.status()); } let info: UserInfo = resp.json().await?; Ok(Some(info)) } /// Fetch all projects for a creator with item counts and revenue. pub async fn get_projects(&self, user_id: &str) -> anyhow::Result> { let url = format!("{}/api/internal/creator/projects", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "get_projects").await } /// Fetch items in a project. pub async fn get_project_items( &self, project_id: &str, user_id: &str, ) -> anyhow::Result> { let url = format!( "{}/api/internal/creator/projects/{}/items", self.base_url, project_id ); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "get_project_items").await } /// Fetch period comparison stats for a creator. pub async fn get_stats(&self, user_id: &str, range: &str) -> anyhow::Result { let url = format!("{}/api/internal/creator/stats", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id), ("range", range)]) .send() .await?; json_response(resp, "get_stats").await } /// Fetch storage usage and limits for a creator. pub async fn get_storage_info(&self, user_id: &str) -> anyhow::Result { let url = format!("{}/api/internal/creator/storage", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "get_storage_info").await } /// Create an item in a project. pub async fn create_item( &self, user_id: &str, project_id: &str, title: &str, item_type: &str, price_cents: i32, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/items", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .json(&serde_json::json!({ "user_id": user_id, "project_id": project_id, "title": title, "item_type": item_type, "price_cents": price_cents, })) .send() .await?; json_response(resp, "create_item").await } /// Get a presigned S3 upload URL. pub async fn presign_upload( &self, user_id: &str, item_id: &str, file_type: &str, file_name: &str, content_type: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/upload/presign", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .json(&serde_json::json!({ "user_id": user_id, "item_id": item_id, "file_type": file_type, "file_name": file_name, "content_type": content_type, })) .send() .await?; json_response(resp, "presign_upload").await } /// Confirm a completed S3 upload. pub async fn confirm_upload( &self, user_id: &str, item_id: &str, file_type: &str, s3_key: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/upload/confirm", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .json(&serde_json::json!({ "user_id": user_id, "item_id": item_id, "file_type": file_type, "s3_key": s3_key, })) .send() .await?; #[derive(Deserialize)] struct Resp { success: bool, } let r: Resp = json_response(resp, "confirm_upload").await?; Ok(r.success) } /// Fetch full item detail. pub async fn get_item_detail( &self, user_id: &str, item_id: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "get_item_detail").await } /// Update item fields. Only non-None fields are changed. pub async fn update_item( &self, user_id: &str, item_id: &str, title: Option<&str>, description: Option<&str>, price_cents: Option, is_public: Option, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id); let mut body = serde_json::json!({ "user_id": user_id }); if let Some(t) = title { body["title"] = serde_json::Value::String(t.to_string()); } if let Some(d) = description { body["description"] = serde_json::Value::String(d.to_string()); } if let Some(p) = price_cents { body["price_cents"] = serde_json::json!(p); } if let Some(v) = is_public { body["is_public"] = serde_json::json!(v); } let resp = self .http .put(&url) .bearer_auth(&self.service_token) .json(&body) .send() .await?; json_response(resp, "update_item").await } /// Delete an item permanently. pub async fn delete_item(&self, user_id: &str, item_id: &str) -> anyhow::Result<()> { let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id); let resp = self .http .delete(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; empty_response(resp, "delete_item").await } /// Publish an item (set is_public=true). pub async fn publish_item( &self, user_id: &str, item_id: &str, ) -> anyhow::Result { let url = format!( "{}/api/internal/creator/items/{}/publish", self.base_url, item_id ); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .json(&serde_json::json!({ "user_id": user_id })) .send() .await?; json_response(resp, "publish_item").await } /// Unpublish an item (set is_public=false). pub async fn unpublish_item( &self, user_id: &str, item_id: &str, ) -> anyhow::Result { let url = format!( "{}/api/internal/creator/items/{}/unpublish", self.base_url, item_id ); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .json(&serde_json::json!({ "user_id": user_id })) .send() .await?; json_response(resp, "unpublish_item").await } /// Fetch versions for an item. pub async fn get_item_versions( &self, user_id: &str, item_id: &str, ) -> anyhow::Result> { let url = format!( "{}/api/internal/creator/items/{}/versions", self.base_url, item_id ); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "get_item_versions").await } /// Upload a file to S3 using a presigned URL. pub async fn upload_to_s3( &self, presigned_url: &str, file_path: &std::path::Path, content_type: &str, cache_control: Option<&str>, ) -> anyhow::Result<()> { let data = tokio::fs::read(file_path).await?; let mut req = self .http .put(presigned_url) .header("content-type", content_type) .body(data); if let Some(cc) = cache_control { req = req.header("cache-control", cc); } let resp = req.send().await?; if !resp.status().is_success() { anyhow::bail!("S3 upload failed: HTTP {}", resp.status()); } Ok(()) } // ── Blog posts ── /// List blog posts for a project. pub async fn list_blog_posts( &self, user_id: &str, project_id: &str, ) -> anyhow::Result> { let url = format!( "{}/api/internal/creator/projects/{}/blog", self.base_url, project_id ); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "list_blog_posts").await } /// Create a blog post. pub async fn create_blog_post( &self, user_id: &str, project_id: &str, title: &str, body_markdown: &str, publish: bool, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/blog", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .json(&serde_json::json!({ "user_id": user_id, "project_id": project_id, "title": title, "body_markdown": body_markdown, "publish": publish, })) .send() .await?; json_response(resp, "create_blog_post").await } /// Delete a blog post. pub async fn delete_blog_post(&self, user_id: &str, post_id: &str) -> anyhow::Result<()> { let url = format!("{}/api/internal/creator/blog/{}", self.base_url, post_id); let resp = self .http .delete(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; empty_response(resp, "delete_blog_post").await } // ── Promo codes ── /// List promo codes for a creator. pub async fn list_promo_codes(&self, user_id: &str) -> anyhow::Result> { let url = format!("{}/api/internal/creator/promo-codes", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "list_promo_codes").await } /// Create a promo code. pub async fn create_promo_code( &self, user_id: &str, code: &str, discount_type: &str, discount_value: i32, max_uses: Option, project_id: Option<&str>, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/promo-codes", self.base_url); let mut body = serde_json::json!({ "user_id": user_id, "code": code, "code_purpose": "discount", "discount_type": discount_type, "discount_value": discount_value, }); if let Some(max) = max_uses { body["max_uses"] = serde_json::json!(max); } if let Some(pid) = project_id { body["project_id"] = serde_json::json!(pid); } let resp = self .http .post(&url) .bearer_auth(&self.service_token) .json(&body) .send() .await?; json_response(resp, "create_promo_code").await } /// Delete a promo code. pub async fn delete_promo_code(&self, user_id: &str, code_id: &str) -> anyhow::Result<()> { let url = format!( "{}/api/internal/creator/promo-codes/{}", self.base_url, code_id ); let resp = self .http .delete(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; empty_response(resp, "delete_promo_code").await } // ── License keys ── /// List license keys for an item. pub async fn list_license_keys( &self, user_id: &str, item_id: &str, ) -> anyhow::Result> { let url = format!( "{}/api/internal/creator/items/{}/keys", self.base_url, item_id ); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "list_license_keys").await } /// Generate a new license key for an item. pub async fn generate_license_key( &self, user_id: &str, item_id: &str, ) -> anyhow::Result { let url = format!( "{}/api/internal/creator/items/{}/keys", self.base_url, item_id ); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .json(&serde_json::json!({ "user_id": user_id })) .send() .await?; json_response(resp, "generate_license_key").await } /// Revoke a license key. pub async fn revoke_license_key( &self, user_id: &str, key_id: &str, ) -> anyhow::Result<()> { let url = format!( "{}/api/internal/creator/keys/{}/revoke", self.base_url, key_id ); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .json(&serde_json::json!({ "user_id": user_id })) .send() .await?; empty_response(resp, "revoke_license_key").await } // ── Analytics ── /// Get analytics data (timeseries, period comparison, top projects). pub async fn get_analytics( &self, user_id: &str, range: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/creator/analytics", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id), ("range", range)]) .send() .await?; json_response(resp, "get_analytics").await } /// Get recent seller transactions. pub async fn get_transactions(&self, user_id: &str) -> anyhow::Result> { let url = format!("{}/api/internal/creator/transactions", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "get_transactions").await } /// Export sales as CSV string. pub async fn export_sales_csv(&self, user_id: &str) -> anyhow::Result { let url = format!("{}/api/internal/creator/export/sales", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "export_sales_csv").await } // ── SSH keys ── /// Authorize a git operation and get the on-disk repo path. pub async fn git_authorize( &self, user_id: &str, operation: &str, owner: &str, repo_name: &str, ) -> anyhow::Result { let url = format!("{}/api/internal/git/authorize", self.base_url); let resp = self .http .post(&url) .bearer_auth(&self.service_token) .json(&serde_json::json!({ "user_id": user_id, "operation": operation, "owner": owner, "repo_name": repo_name, })) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_else(|e| { tracing::warn!(error = %e, "failed to read git_authorize error body"); String::new() }); // Parse JSON error if available, fall back to status text let msg = serde_json::from_str::(&body) .ok() .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)) .unwrap_or_else(|| format!("HTTP {status}")); anyhow::bail!("{msg}"); } Ok(resp.json().await?) } /// List registered SSH keys for a user. pub async fn list_ssh_keys(&self, user_id: &str) -> anyhow::Result> { let url = format!("{}/api/internal/creator/ssh-keys", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .query(&[("user_id", user_id)]) .send() .await?; json_response(resp, "list_ssh_keys").await } }