Skip to main content

memvid_cli/
org_ticket_cache.rs

1//! Organisation-level ticket caching
2//!
3//! Caches org-level tickets locally to avoid repeated API calls.
4//! Tickets are automatically refreshed when expired.
5
6use std::fs;
7use std::path::PathBuf;
8
9use anyhow::{Context, Result};
10use chrono::Utc;
11
12use crate::api::{fetch_org_ticket, OrgTicket, OrgTicketResponse};
13use crate::config::CliConfig;
14
15const ORG_TICKET_FILE: &str = "org_ticket.json";
16
17/// How often to refresh ticket for write operations (5 minutes)
18const WRITE_OP_REFRESH_SECS: i64 = 300;
19
20/// Cached org ticket with metadata
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22pub struct CachedOrgTicket {
23    pub ticket: OrgTicket,
24    pub plan_id: String,
25    pub plan_name: String,
26    pub org_id: String,
27    pub org_name: String,
28    pub total_storage_bytes: u64,
29    pub subscription_status: String,
30    /// ISO date when paid plan started
31    #[serde(default)]
32    pub plan_start_date: Option<String>,
33    /// ISO date when current billing period ends (renews)
34    #[serde(default)]
35    pub current_period_end: Option<String>,
36    /// ISO date when paid plan ends (for canceled subscriptions in grace period)
37    #[serde(default)]
38    pub plan_end_date: Option<String>,
39    /// Unix timestamp when this ticket was cached (for freshness checks)
40    #[serde(default)]
41    pub cached_at: i64,
42}
43
44impl CachedOrgTicket {
45    /// Check if the cached ticket has expired
46    pub fn is_expired(&self) -> bool {
47        self.ticket.is_expired()
48    }
49
50    /// Get capacity in bytes from the ticket
51    pub fn capacity_bytes(&self) -> u64 {
52        self.ticket.capacity_bytes
53    }
54
55    /// Check if the plan is a paid plan
56    pub fn is_paid(&self) -> bool {
57        self.ticket.is_paid()
58    }
59
60    /// Check if the subscription is canceled but still in grace period
61    pub fn is_in_grace_period(&self) -> bool {
62        if self.subscription_status != "canceled" {
63            return false;
64        }
65        if let Some(ref end_date) = self.plan_end_date {
66            // Parse ISO date and check if still valid
67            if let Ok(end) = chrono::DateTime::parse_from_rfc3339(end_date) {
68                return end > Utc::now();
69            }
70        }
71        false
72    }
73
74    /// Get days remaining in grace period (None if not in grace period)
75    pub fn grace_period_days_remaining(&self) -> Option<i64> {
76        if self.subscription_status != "canceled" {
77            return None;
78        }
79        if let Some(ref end_date) = self.plan_end_date {
80            if let Ok(end) = chrono::DateTime::parse_from_rfc3339(end_date) {
81                let now = Utc::now();
82                if end > now {
83                    let duration = end.signed_duration_since(now);
84                    return Some(duration.num_days());
85                }
86            }
87        }
88        None
89    }
90
91    /// Check if the ticket is too old for write operations
92    /// Write operations require fresher subscription status to prevent
93    /// users from continuing to use the CLI after cancellation
94    pub fn is_stale_for_writes(&self) -> bool {
95        if self.cached_at == 0 {
96            // Old cache format without cached_at - treat as stale
97            return true;
98        }
99        let now = Utc::now().timestamp();
100        now - self.cached_at > WRITE_OP_REFRESH_SECS
101    }
102}
103
104/// Get the path to the org ticket cache file
105fn cache_path(config: &CliConfig) -> PathBuf {
106    config.cache_dir.join(ORG_TICKET_FILE)
107}
108
109/// Load a cached org ticket from disk
110pub fn load(config: &CliConfig) -> Result<CachedOrgTicket> {
111    let path = cache_path(config);
112    let data = fs::read(&path).with_context(|| "no cached org ticket available")?;
113    let cached: CachedOrgTicket = serde_json::from_slice(&data)
114        .with_context(|| format!("failed to parse cached org ticket: {}", path.display()))?;
115    Ok(cached)
116}
117
118/// Store an org ticket response to disk
119pub fn store(config: &CliConfig, response: &OrgTicketResponse) -> Result<()> {
120    let path = cache_path(config);
121    if let Some(parent) = path.parent() {
122        fs::create_dir_all(parent).with_context(|| {
123            format!(
124                "failed to create org ticket cache directory: {}",
125                parent.display()
126            )
127        })?;
128    }
129
130    let cached = CachedOrgTicket {
131        ticket: response.ticket.clone(),
132        plan_id: response.plan.id.clone(),
133        plan_name: response.plan.name.clone(),
134        org_id: response.organisation.id.clone(),
135        org_name: response.organisation.name.clone(),
136        total_storage_bytes: response.organisation.total_storage_bytes,
137        subscription_status: response.subscription.status.clone(),
138        plan_start_date: response.subscription.plan_start_date.clone(),
139        current_period_end: response.subscription.current_period_end.clone(),
140        plan_end_date: response.subscription.plan_end_date.clone(),
141        cached_at: Utc::now().timestamp(),
142    };
143
144    let tmp = path.with_extension("json.tmp");
145    let data = serde_json::to_vec_pretty(&cached)?;
146    fs::write(&tmp, data)
147        .with_context(|| format!("failed to write org ticket cache file: {}", tmp.display()))?;
148    fs::rename(&tmp, &path).with_context(|| {
149        format!(
150            "failed to atomically persist org ticket cache file: {}",
151            path.display()
152        )
153    })?;
154    Ok(())
155}
156
157/// Clear the cached org ticket
158pub fn clear(config: &CliConfig) -> Result<()> {
159    let path = cache_path(config);
160    if path.exists() {
161        fs::remove_file(&path).with_context(|| {
162            format!("failed to remove org ticket cache file: {}", path.display())
163        })?;
164    }
165    Ok(())
166}
167
168/// Get a valid org ticket, fetching from API if needed
169///
170/// This function:
171/// 1. Tries to load a cached ticket
172/// 2. If cached ticket is valid (not expired), returns it
173/// 3. If no cache or expired, fetches a new ticket from the API
174/// 4. Caches the new ticket and returns it
175pub fn get_or_refresh(config: &CliConfig) -> Result<CachedOrgTicket> {
176    // Try cached ticket first
177    if let Ok(cached) = load(config) {
178        if !cached.is_expired() {
179            return Ok(cached);
180        }
181        log::debug!("Cached org ticket expired, refreshing...");
182    }
183
184    // Fetch new ticket
185    refresh(config)
186}
187
188/// Force refresh the org ticket from the API
189pub fn refresh(config: &CliConfig) -> Result<CachedOrgTicket> {
190    log::debug!("Fetching org ticket from API...");
191    let response = fetch_org_ticket(config)?;
192    store(config, &response)?;
193    load(config)
194}
195
196/// Get org ticket if API key is configured, otherwise return None
197///
198/// This is useful for optional ticket validation - if no API key is set,
199/// we fall back to free tier limits without requiring authentication.
200///
201/// When API key is set, this automatically syncs the plan ticket from the
202/// dashboard if needed (first use or expired). This enables seamless
203/// capacity validation without explicit `memvid plan sync`.
204pub fn get_optional(config: &CliConfig) -> Option<CachedOrgTicket> {
205    // No API key = free tier, silent fallback
206    if config.api_key.is_none() {
207        log::debug!("No API key set, using free tier limits");
208        return None;
209    }
210
211    // Check if we need to fetch (no cache or expired)
212    let needs_fetch = match load(config) {
213        Ok(cached) => cached.is_expired(),
214        Err(_) => true,
215    };
216
217    if needs_fetch {
218        // Silent auto-sync on first use or when expired
219        log::debug!("Auto-syncing plan ticket from dashboard...");
220        match refresh(config) {
221            Ok(ticket) => {
222                log::info!(
223                    "Plan synced: {} ({})",
224                    ticket.plan_name,
225                    format_capacity(ticket.capacity_bytes())
226                );
227                return Some(ticket);
228            }
229            Err(err) => {
230                // Check if it's an auth error - silently fall back to free tier
231                let err_str = err.to_string();
232                if err_str.contains("Invalid API key") || err_str.contains("401") {
233                    log::debug!("API key invalid, using free tier limits");
234                } else {
235                    // Only warn for non-auth errors (network issues, etc.)
236                    log::warn!("Failed to sync plan (using free tier limits): {}", err);
237                }
238                return None;
239            }
240        }
241    }
242
243    // Use cached ticket
244    match load(config) {
245        Ok(ticket) => Some(ticket),
246        Err(_) => None,
247    }
248}
249
250/// Format capacity bytes for display
251fn format_capacity(bytes: u64) -> String {
252    if bytes >= 1024 * 1024 * 1024 * 1024 {
253        format!(
254            "{:.1} TB",
255            bytes as f64 / (1024.0 * 1024.0 * 1024.0 * 1024.0)
256        )
257    } else if bytes >= 1024 * 1024 * 1024 {
258        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
259    } else if bytes >= 1024 * 1024 {
260        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
261    } else if bytes >= 1024 {
262        format!("{:.1} KB", bytes as f64 / 1024.0)
263    } else {
264        format!("{} B", bytes)
265    }
266}
267
268/// Get a fresh org ticket for write operations
269///
270/// Write operations (put, find, ask) require up-to-date subscription status
271/// to prevent users from continuing to use the CLI after cancellation.
272///
273/// Smart refresh logic:
274/// - If cached status is "active" → only refresh if older than 5 minutes
275/// - If cached status is "canceled"/"inactive" → always refresh (instant unblock on reactivation)
276///
277/// This ensures:
278/// - Active users get fast operations (minimal API calls)
279/// - Canceled users can't abuse the CLI (checked every operation)
280/// - Reactivated users get instant access (no waiting for cache expiry)
281pub fn get_fresh_for_writes(config: &CliConfig) -> Option<CachedOrgTicket> {
282    // No API key = free tier, no ticket needed
283    if config.api_key.is_none() {
284        return None;
285    }
286
287    // Check if we have a cached ticket and determine refresh strategy
288    let needs_refresh = match load(config) {
289        Ok(cached) => {
290            if cached.is_expired() {
291                true
292            } else {
293                // Smart refresh: active users get 5-min cache, others always refresh
294                let status = cached.subscription_status.as_str();
295                if status == "active" || status == "trialing" {
296                    // Active subscription: only refresh if stale (> 5 min)
297                    cached.is_stale_for_writes()
298                } else {
299                    // Canceled/inactive: always refresh to catch reactivation instantly
300                    log::debug!(
301                        "Non-active status '{}', refreshing to check for reactivation",
302                        status
303                    );
304                    true
305                }
306            }
307        }
308        Err(_) => true,
309    };
310
311    if needs_refresh {
312        log::debug!("Refreshing ticket for write operation...");
313        match refresh(config) {
314            Ok(ticket) => return Some(ticket),
315            Err(err) => {
316                let err_str = err.to_string();
317                if err_str.contains("Invalid API key") || err_str.contains("401") {
318                    log::debug!("API key invalid, using free tier limits");
319                } else {
320                    log::warn!("Failed to refresh ticket: {}", err);
321                }
322                return None;
323            }
324        }
325    }
326
327    // Use cached ticket (it's fresh enough)
328    load(config).ok()
329}