Skip to main content

oauth_db_cli/commands/
app.rs

1use crate::cli::{AppCommands, Cli};
2use crate::client::ApiClient;
3use crate::config::Config;
4use crate::error::{CliError, Result};
5use crate::output::{create_table_with_headers, format_output, print_success, print_warning, OutputFormat};
6use chrono::{DateTime, Utc};
7use dialoguer::Confirm;
8use serde::{Deserialize, Serialize};
9
10pub async fn execute(command: &AppCommands, cli: &Cli) -> Result<()> {
11    let output_format = cli.output.as_ref().map(|s| OutputFormat::from_str(s)).unwrap_or(OutputFormat::Table);
12
13    match command {
14        AppCommands::Create { name, default_db, allowed_dbs, max_users, storage_quota, max_connections, qps_limit } => {
15            create_app(name.clone(), default_db.clone(), allowed_dbs.clone(), *max_users, *storage_quota, *max_connections, *qps_limit, &output_format).await
16        }
17        AppCommands::List { status, page, page_size } => {
18            list_apps(status.clone(), *page, *page_size, &output_format).await
19        }
20        AppCommands::Show { app_id } => {
21            show_app(app_id.clone(), &output_format).await
22        }
23        AppCommands::Update { app_id, name, default_db, allowed_dbs, max_users, storage_quota, max_connections, qps_limit } => {
24            update_app(app_id.clone(), name.clone(), default_db.clone(), allowed_dbs.clone(), *max_users, *storage_quota, *max_connections, *qps_limit, &output_format).await
25        }
26        AppCommands::Enable { app_id } => {
27            enable_app(app_id.clone(), &output_format).await
28        }
29        AppCommands::Disable { app_id } => {
30            disable_app(app_id.clone(), &output_format).await
31        }
32        AppCommands::Delete { app_id, force } => {
33            delete_app(app_id.clone(), *force, &output_format).await
34        }
35        AppCommands::ResetToken { app_id } => {
36            reset_token(app_id.clone(), &output_format).await
37        }
38        AppCommands::Stats { app_id } => {
39            show_stats(app_id.clone(), &output_format).await
40        }
41    }
42}
43
44#[derive(Debug, Serialize, Deserialize)]
45pub struct CreateAppRequest {
46    pub app_name: String,
47    pub default_database: String,
48    pub allowed_databases: Vec<String>,
49    pub max_users: u32,
50    pub default_storage_quota_mb: u32,
51    pub default_max_connections: u32,
52    pub default_qps: u32,
53}
54
55#[derive(Debug, Serialize, Deserialize)]
56pub struct CreateAppResponse {
57    pub app_id: String,
58    pub app_name: String,
59    pub app_token: String,
60    pub default_database: String,
61    pub allowed_databases: Vec<String>,
62    pub max_users: u32,
63    pub status: String,
64    pub created_at: String,
65}
66
67#[derive(Debug, Serialize, Deserialize)]
68pub struct AppListResponse {
69    pub items: Vec<AppListItem>,
70    pub total: u32,
71    pub page: u32,
72    pub page_size: u32,
73}
74
75#[derive(Debug, Serialize, Deserialize)]
76pub struct AppListItem {
77    pub app_id: String,
78    pub app_name: String,
79    pub status: String,
80    pub user_count: u32,
81    pub created_at: String,
82}
83
84#[derive(Debug, Serialize, Deserialize)]
85pub struct AppDetail {
86    pub app_id: String,
87    pub app_name: String,
88    pub default_database: String,
89    pub allowed_databases: Vec<String>,
90    pub status: String,
91    pub max_users: u32,
92    pub default_storage_quota_mb: u32,
93    pub default_max_connections: u32,
94    pub default_qps: u32,
95    pub user_count: u32,
96    pub total_storage_mb: f64,
97    pub created_at: String,
98    pub updated_at: String,
99}
100
101#[derive(Debug, Serialize, Deserialize)]
102pub struct UpdateAppRequest {
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub app_name: Option<String>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub default_database: Option<String>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub allowed_databases: Option<Vec<String>>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub max_users: Option<u32>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub default_storage_quota_mb: Option<u32>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub default_max_connections: Option<u32>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub default_qps: Option<u32>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub status: Option<String>,
119}
120
121#[derive(Debug, Serialize, Deserialize)]
122pub struct UpdateAppResponse {
123    pub app_id: String,
124    pub updated_fields: Vec<String>,
125    pub updated_at: String,
126}
127
128#[derive(Debug, Serialize, Deserialize)]
129pub struct DeleteAppResponse {
130    pub app_id: String,
131    pub deleted: bool,
132    pub affected_users: u32,
133    pub workspace_cleanup: String,
134}
135
136#[derive(Debug, Serialize, Deserialize)]
137pub struct ResetTokenResponse {
138    pub app_id: String,
139    pub app_token: String,
140    pub rotated_at: String,
141}
142
143#[derive(Debug, Serialize, Deserialize)]
144pub struct AppStats {
145    pub app_id: String,
146    pub user_count: u32,
147    pub active_user_count: u32,
148    pub total_storage_mb: f64,
149    pub total_connections: u32,
150    pub recent_users: Vec<RecentUser>,
151}
152
153#[derive(Debug, Serialize, Deserialize)]
154pub struct RecentUser {
155    pub user_uid: String,
156    pub label: Option<String>,
157    pub last_accessed_at: String,
158}
159
160pub async fn create_app(
161    name: String,
162    default_db: String,
163    allowed_dbs: String,
164    max_users: u32,
165    storage_quota: u32,
166    max_connections: u32,
167    qps_limit: u32,
168    output_format: &OutputFormat,
169) -> Result<()> {
170    let config = Config::load()?;
171    let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
172
173    let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
174
175    let allowed_databases: Vec<String> = allowed_dbs
176        .split(',')
177        .map(|s| s.trim().to_string())
178        .filter(|s| !s.is_empty())
179        .collect();
180
181    let request = CreateAppRequest {
182        app_name: name,
183        default_database: default_db,
184        allowed_databases,
185        max_users,
186        default_storage_quota_mb: storage_quota,
187        default_max_connections: max_connections,
188        default_qps: qps_limit,
189    };
190
191    let request_json = serde_json::to_value(&request)
192        .map_err(|e| CliError::SerializationError(format!("Failed to serialize request: {}", e)))?;
193    let response: CreateAppResponse = client.post("/api/v1/apps", &request_json).await?;
194
195    match output_format {
196        OutputFormat::Json | OutputFormat::Yaml => {
197            println!("{}", format_output(&response, *output_format)?);
198        }
199        OutputFormat::Table => {
200            print_success("App created successfully");
201            println!();
202            println!("  App ID: {}", response.app_id);
203            println!("  App Name: {}", response.app_name);
204            println!("  App Token: {}", response.app_token);
205            println!();
206            print_warning("⚠️  Save the app token - it won't be shown again!");
207        }
208    }
209
210    Ok(())
211}
212
213pub async fn list_apps(
214    status: Option<String>,
215    page: u32,
216    page_size: u32,
217    output_format: &OutputFormat,
218) -> Result<()> {
219    let config = Config::load()?;
220    let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
221
222    let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
223
224    let mut query = vec![
225        ("page", page.to_string()),
226        ("page_size", page_size.to_string()),
227    ];
228
229    if let Some(s) = status {
230        query.push(("status", s));
231    }
232
233    let query_refs: Vec<(&str, String)> = query.iter().map(|(k, v)| (*k, v.clone())).collect();
234    let response: AppListResponse = client.get("/api/v1/apps", &query_refs).await?;
235
236    match output_format {
237        OutputFormat::Json | OutputFormat::Yaml => {
238            println!("{}", format_output(&response, *output_format)?);
239        }
240        OutputFormat::Table => {
241            if response.items.is_empty() {
242                println!("No applications found.");
243                return Ok(());
244            }
245
246            let mut table = create_table_with_headers(vec!["APP ID", "NAME", "STATUS", "USERS", "CREATED AT"]);
247
248            for item in &response.items {
249                let created_at = parse_and_format_date(&item.created_at);
250                table.add_row(vec![
251                    &item.app_id,
252                    &item.app_name,
253                    &item.status,
254                    &item.user_count.to_string(),
255                    &created_at,
256                ]);
257            }
258
259            println!("{}", table);
260            println!(
261                "\nShowing {} of {} apps (page {}/{})",
262                response.items.len(),
263                response.total,
264                response.page,
265                (response.total + response.page_size - 1) / response.page_size
266            );
267        }
268    }
269
270    Ok(())
271}
272
273pub async fn show_app(app_id: String, output_format: &OutputFormat) -> Result<()> {
274    let config = Config::load()?;
275    let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
276
277    let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
278
279    let url = format!("/api/v1/apps/{}", app_id);
280    let response: AppDetail = client.get(&url, &[]).await?;
281
282    match output_format {
283        OutputFormat::Json | OutputFormat::Yaml => {
284            println!("{}", format_output(&response, *output_format)?);
285        }
286        OutputFormat::Table => {
287            println!("Application Details");
288            println!("==================");
289            println!();
290            println!("  App ID: {}", response.app_id);
291            println!("  Name: {}", response.app_name);
292            println!("  Status: {}", response.status);
293            println!();
294            println!("Database Configuration:");
295            println!("  Default Database: {}", response.default_database);
296            println!("  Allowed Databases: {}", response.allowed_databases.join(", "));
297            println!();
298            println!("Quotas:");
299            println!("  Max Users: {}", response.max_users);
300            println!("  Storage Quota (per user): {} MB", response.default_storage_quota_mb);
301            println!("  Max Connections (per user): {}", response.default_max_connections);
302            println!("  QPS Limit (per user): {}", response.default_qps);
303            println!();
304            println!("Usage:");
305            println!("  Current Users: {}", response.user_count);
306            println!("  Total Storage: {:.2} MB", response.total_storage_mb);
307            println!();
308            println!("  Created: {}", parse_and_format_date(&response.created_at));
309            println!("  Updated: {}", parse_and_format_date(&response.updated_at));
310        }
311    }
312
313    Ok(())
314}
315
316pub async fn update_app(
317    app_id: String,
318    name: Option<String>,
319    default_db: Option<String>,
320    allowed_dbs: Option<String>,
321    max_users: Option<u32>,
322    storage_quota: Option<u32>,
323    max_connections: Option<u32>,
324    qps_limit: Option<u32>,
325    output_format: &OutputFormat,
326) -> Result<()> {
327    let config = Config::load()?;
328    let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
329
330    let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
331
332    let allowed_databases = allowed_dbs.map(|dbs| {
333        dbs.split(',')
334            .map(|s| s.trim().to_string())
335            .filter(|s| !s.is_empty())
336            .collect()
337    });
338
339    let request = UpdateAppRequest {
340        app_name: name,
341        default_database: default_db,
342        allowed_databases,
343        max_users,
344        default_storage_quota_mb: storage_quota,
345        default_max_connections: max_connections,
346        default_qps: qps_limit,
347        status: None,
348    };
349
350    let url = format!("/api/v1/apps/{}", app_id);
351    let request_json = serde_json::to_value(&request)
352        .map_err(|e| CliError::SerializationError(format!("Failed to serialize request: {}", e)))?;
353    let response: UpdateAppResponse = client.patch(&url, &request_json).await?;
354
355    match output_format {
356        OutputFormat::Json | OutputFormat::Yaml => {
357            println!("{}", format_output(&response, *output_format)?);
358        }
359        OutputFormat::Table => {
360            print_success("App updated successfully");
361            println!();
362            println!("  App ID: {}", response.app_id);
363            println!("  Updated Fields: {}", response.updated_fields.join(", "));
364            println!("  Updated At: {}", parse_and_format_date(&response.updated_at));
365        }
366    }
367
368    Ok(())
369}
370
371pub async fn enable_app(app_id: String, output_format: &OutputFormat) -> Result<()> {
372    change_app_status(app_id, "active", output_format).await
373}
374
375pub async fn disable_app(app_id: String, output_format: &OutputFormat) -> Result<()> {
376    change_app_status(app_id, "disabled", output_format).await
377}
378
379async fn change_app_status(
380    app_id: String,
381    status: &str,
382    output_format: &OutputFormat,
383) -> Result<()> {
384    let config = Config::load()?;
385    let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
386
387    let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
388
389    let request = UpdateAppRequest {
390        app_name: None,
391        default_database: None,
392        allowed_databases: None,
393        max_users: None,
394        default_storage_quota_mb: None,
395        default_max_connections: None,
396        default_qps: None,
397        status: Some(status.to_string()),
398    };
399
400    let url = format!("/api/v1/apps/{}", app_id);
401    let request_json = serde_json::to_value(&request)
402        .map_err(|e| CliError::SerializationError(format!("Failed to serialize request: {}", e)))?;
403    let response: UpdateAppResponse = client.patch(&url, &request_json).await?;
404
405    match output_format {
406        OutputFormat::Json | OutputFormat::Yaml => {
407            println!("{}", format_output(&response, *output_format)?);
408        }
409        OutputFormat::Table => {
410            let action = if status == "active" { "enabled" } else { "disabled" };
411            print_success(&format!("App {} successfully", action));
412        }
413    }
414
415    Ok(())
416}
417
418pub async fn delete_app(app_id: String, force: bool, output_format: &OutputFormat) -> Result<()> {
419    let config = Config::load()?;
420    let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
421
422    let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
423
424    // Get app details first for confirmation
425    let url = format!("/api/v1/apps/{}", app_id);
426    let app: AppDetail = client.get(&url, &[]).await?;
427
428    if !force {
429        println!();
430        print_warning("⚠️  WARNING: This will permanently delete the app and all its users.");
431        println!("   App ID: {}", app.app_id);
432        println!("   App Name: {}", app.app_name);
433        println!("   Users: {}", app.user_count);
434        println!();
435
436        let confirmed = Confirm::new()
437            .with_prompt("Are you sure?")
438            .default(false)
439            .interact()
440            .map_err(|e| CliError::InvalidInput(format!("Failed to read input: {}", e)))?;
441
442        if !confirmed {
443            println!("Cancelled.");
444            return Ok(());
445        }
446    }
447
448    let response: DeleteAppResponse = client.delete(&url).await?;
449
450    match output_format {
451        OutputFormat::Json | OutputFormat::Yaml => {
452            println!("{}", format_output(&response, *output_format)?);
453        }
454        OutputFormat::Table => {
455            print_success("App deleted successfully");
456            println!();
457            println!("  Affected Users: {}", response.affected_users);
458            println!("  Workspace Cleanup: {}", response.workspace_cleanup);
459        }
460    }
461
462    Ok(())
463}
464
465pub async fn reset_token(app_id: String, output_format: &OutputFormat) -> Result<()> {
466    let config = Config::load()?;
467    let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
468
469    let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
470
471    // Confirm action
472    let confirmed = Confirm::new()
473        .with_prompt("This will invalidate the old token. Continue?")
474        .default(false)
475        .interact()
476        .map_err(|e| CliError::InvalidInput(format!("Failed to read input: {}", e)))?;
477
478    if !confirmed {
479        println!("Cancelled.");
480        return Ok(());
481    }
482
483    let url = format!("/api/v1/apps/{}/rotate-token", app_id);
484    let response: ResetTokenResponse = client.post(&url, &serde_json::json!({})).await?;
485
486    match output_format {
487        OutputFormat::Json | OutputFormat::Yaml => {
488            println!("{}", format_output(&response, *output_format)?);
489        }
490        OutputFormat::Table => {
491            print_success("App token reset successfully");
492            println!();
493            println!("  App ID: {}", response.app_id);
494            println!("  New Token: {}", response.app_token);
495            println!();
496            print_warning("⚠️  Save the new token - it won't be shown again!");
497        }
498    }
499
500    Ok(())
501}
502
503pub async fn show_stats(app_id: String, output_format: &OutputFormat) -> Result<()> {
504    let config = Config::load()?;
505    let account = config.get_default_account().ok_or(CliError::NotLoggedIn)?;
506
507    let client = ApiClient::new(account.server.clone(), Some(account.token.clone()))?;
508
509    let url = format!("/api/v1/apps/{}/stats", app_id);
510    let response: AppStats = client.get(&url, &[]).await?;
511
512    match output_format {
513        OutputFormat::Json | OutputFormat::Yaml => {
514            println!("{}", format_output(&response, *output_format)?);
515        }
516        OutputFormat::Table => {
517            println!("Application Statistics");
518            println!("=====================");
519            println!();
520            println!("  App ID: {}", response.app_id);
521            println!("  Total Users: {}", response.user_count);
522            println!("  Active Users: {}", response.active_user_count);
523            println!("  Total Storage: {:.2} MB", response.total_storage_mb);
524            println!("  Active Connections: {}", response.total_connections);
525            println!();
526
527            if !response.recent_users.is_empty() {
528                println!("Recent Users:");
529                let mut table = create_table_with_headers(vec!["USER UID", "LABEL", "LAST ACCESSED"]);
530
531                for user in &response.recent_users {
532                    let label = user.label.as_deref().unwrap_or("-");
533                    let last_accessed = parse_and_format_date(&user.last_accessed_at);
534                    table.add_row(vec![&user.user_uid, label, &last_accessed]);
535                }
536
537                println!("{}", table);
538            }
539        }
540    }
541
542    Ok(())
543}
544
545fn parse_and_format_date(date_str: &str) -> String {
546    DateTime::parse_from_rfc3339(date_str)
547        .map(|dt| dt.with_timezone(&Utc).format("%Y-%m-%d %H:%M:%S UTC").to_string())
548        .unwrap_or_else(|_| date_str.to_string())
549}