bctx-cloud-core 0.1.6

bctx-cloud-core — cloud client and server for Vault sync, dashboard API, billing
Documentation
use crate::client::upgrade::Tier;
use crate::dashboard::gauge::TokenGauge;
use crate::dashboard::{DailyBucket, DashboardData};
use crate::server::{AppError, AppState, AuthUser};
use axum::{
    extract::{Query, State},
    Json,
};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
pub struct DaysParam {
    #[serde(default = "default_days")]
    pub days: i64,
}
fn default_days() -> i64 {
    30
}

#[derive(Serialize)]
pub struct CommandStat {
    pub program: String,
    pub tokens_sent: i64,
    pub tokens_saved: i64,
    pub savings_pct: f64,
    pub run_count: i64,
}

pub async fn commands(
    State(state): State<AppState>,
    AuthUser(user_id): AuthUser,
    Query(params): Query<DaysParam>,
) -> Result<Json<Vec<CommandStat>>, AppError> {
    let window = format!("-{} days", params.days);
    let conn = state.db.conn();
    let mut stmt = conn
        .prepare(
            "SELECT skill,
                    COALESCE(SUM(tokens_sent), 0),
                    COALESCE(SUM(tokens_saved), 0),
                    COUNT(*) as runs
             FROM usage
             WHERE user_id=?1 AND skill!='' AND recorded_at >= datetime('now',?2)
             GROUP BY skill
             ORDER BY SUM(tokens_saved) DESC
             LIMIT 20",
        )
        .map_err(|e| AppError(anyhow::anyhow!(e)))?;
    let stats: Vec<CommandStat> = stmt
        .query_map(rusqlite::params![user_id, window], |row| {
            let ts: i64 = row.get(1)?;
            let sv: i64 = row.get(2)?;
            let pct = if ts > 0 {
                sv as f64 / ts as f64 * 100.0
            } else {
                0.0
            };
            Ok(CommandStat {
                program: row.get(0)?,
                tokens_sent: ts,
                tokens_saved: sv,
                savings_pct: pct,
                run_count: row.get(3)?,
            })
        })
        .map_err(|e| AppError(anyhow::anyhow!(e)))?
        .filter_map(|r| r.ok())
        .collect();
    Ok(Json(stats))
}

pub async fn summary(
    State(state): State<AppState>,
    AuthUser(user_id): AuthUser,
) -> Result<Json<DashboardData>, AppError> {
    let (sent, saved) = state.db.usage_summary(&user_id, 30);
    let cost = saved as f64 * 0.000_015;

    let daily_raw = state.db.usage_by_day(&user_id, 30);
    let daily_buckets: Vec<DailyBucket> = daily_raw
        .into_iter()
        .map(|(date, s, sv)| DailyBucket {
            date,
            tokens_sent: s as u64,
            tokens_saved: sv as u64,
            cost_usd_avoided: sv as f64 * 0.000_015,
            skills_used: Default::default(),
            top_lens: "clarity".to_string(),
        })
        .collect();

    let skill_raw = state.db.skill_breakdown(&user_id, 30);
    let skill_usage: std::collections::HashMap<String, u64> =
        skill_raw.into_iter().map(|(k, v)| (k, v as u64)).collect();

    let top_lens = skill_usage
        .iter()
        .max_by_key(|(_, v)| *v)
        .map(|(k, _)| k.clone())
        .unwrap_or_else(|| "clarity".to_string());

    Ok(Json(DashboardData {
        tokens_used: sent as u64,
        tokens_saved: saved as u64,
        cost_usd_avoided: cost,
        period_days: 30,
        daily_buckets,
        project_breakdown: vec![],
        skill_usage,
        top_lens,
    }))
}

pub async fn gauge(
    State(state): State<AppState>,
    AuthUser(user_id): AuthUser,
) -> Result<Json<TokenGauge>, AppError> {
    let user = state.db.get_user(&user_id);
    let tier = user
        .as_ref()
        .map(|u| Tier::parse_tier(&u.tier))
        .unwrap_or(Tier::Free);
    let (used, _) = state.db.usage_summary(&user_id, 30);
    let quota = tier.cloud_token_quota().unwrap_or(0);
    Ok(Json(TokenGauge {
        used_tokens: used as u64,
        quota_tokens: quota,
        reset_at: next_month_reset(),
        overage_allowed: matches!(tier, Tier::Enterprise),
        tier: format!("{tier:?}").to_lowercase(),
    }))
}

pub async fn history(
    State(state): State<AppState>,
    AuthUser(user_id): AuthUser,
    Query(params): Query<DaysParam>,
) -> Result<Json<Vec<DailyBucket>>, AppError> {
    let raw = state.db.usage_by_day(&user_id, params.days);
    let buckets: Vec<DailyBucket> = raw
        .into_iter()
        .map(|(date, sent, saved)| DailyBucket {
            date,
            tokens_sent: sent as u64,
            tokens_saved: saved as u64,
            cost_usd_avoided: saved as f64 * 0.000_015,
            skills_used: Default::default(),
            top_lens: "clarity".to_string(),
        })
        .collect();
    Ok(Json(buckets))
}

fn next_month_reset() -> String {
    use chrono::Datelike;
    let now = chrono::Utc::now();
    let next = if now.month() == 12 {
        chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1).unwrap()
    } else {
        chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1).unwrap()
    };
    next.format("%Y-%m-%d").to_string()
}