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::server::{AppError, AppState, AuthUser};
use anyhow::anyhow;
use axum::{
    extract::{Path, State},
    Json,
};
use serde::{Deserialize, Serialize};
use vault::fact::MemoFact;

// ── Team namespace DB helpers ─────────────────────────────────────────────────
// Team vaults are stored in the same `vaults` table with a namespace key of
// the form "team:{team_id}:{project_hash}" so no schema migration is needed.

fn team_vault_key(team_id: &str, project_hash: &str) -> String {
    format!("team:{team_id}:{project_hash}")
}

// ── Types ─────────────────────────────────────────────────────────────────────

#[derive(Debug, Serialize, Deserialize)]
pub struct TeamInfo {
    pub team_id: String,
    pub owner_id: String,
    pub name: String,
    pub member_count: usize,
    pub tier: String,
}

#[derive(Deserialize)]
pub struct CreateTeamReq {
    pub name: String,
}

#[derive(Serialize)]
pub struct CreateTeamResp {
    pub team_id: String,
    pub name: String,
}

#[derive(Deserialize)]
pub struct InviteMemberReq {
    pub email: String,
}

#[derive(Serialize)]
pub struct InviteMemberResp {
    pub ok: bool,
    pub invited_user_id: Option<String>,
}

#[derive(Deserialize)]
pub struct TeamVaultPushReq {
    pub project_hash: String,
    pub facts: Vec<MemoFact>,
}

#[derive(Serialize)]
pub struct TeamVaultPushResp {
    pub accepted: usize,
    pub server_version: u64,
}

#[derive(Serialize)]
pub struct TeamVaultPullResp {
    pub facts: Vec<MemoFact>,
    pub server_version: u64,
}

// ── Handlers ──────────────────────────────────────────────────────────────────

/// POST /team — create a new team namespace (Studio+ required).
pub async fn create_team(
    State(state): State<AppState>,
    AuthUser(user_id): AuthUser,
    Json(req): Json<CreateTeamReq>,
) -> Result<Json<CreateTeamResp>, AppError> {
    let user = state
        .db
        .get_user(&user_id)
        .ok_or_else(|| AppError(anyhow!("user not found")))?;
    let tier = Tier::parse_tier(&user.tier);
    if !tier.team_vault_enabled() {
        return Err(AppError(anyhow!(
            "Team namespaces require Studio tier or higher. Upgrade at {}",
            tier.upgrade_url()
        )));
    }

    let team_id = uuid::Uuid::new_v4().to_string();
    // Store team metadata as a vault record under a special key
    let meta = serde_json::json!({
        "name": req.name,
        "owner_id": user_id,
        "members": [user_id],
    });
    state
        .db
        .upsert_vault(&user_id, &format!("team_meta:{team_id}"), &meta.to_string())
        .map_err(|e| AppError(anyhow!(e)))?;

    Ok(Json(CreateTeamResp {
        team_id,
        name: req.name,
    }))
}

/// GET /team/:team_id — get team info.
pub async fn get_team(
    State(state): State<AppState>,
    AuthUser(user_id): AuthUser,
    Path(team_id): Path<String>,
) -> Result<Json<TeamInfo>, AppError> {
    let meta_key = format!("team_meta:{team_id}");
    let record = state
        .db
        .get_vault(&user_id, &meta_key)
        .ok_or_else(|| AppError(anyhow!("team not found or access denied")))?;
    let meta: serde_json::Value = serde_json::from_str(&record.facts_json).unwrap_or_default();
    let members = meta["members"].as_array().map(|a| a.len()).unwrap_or(1);
    let user = state
        .db
        .get_user(&user_id)
        .ok_or_else(|| AppError(anyhow!("user not found")))?;
    Ok(Json(TeamInfo {
        team_id,
        owner_id: meta["owner_id"].as_str().unwrap_or("").to_string(),
        name: meta["name"].as_str().unwrap_or("").to_string(),
        member_count: members,
        tier: user.tier,
    }))
}

/// POST /team/:team_id/invite — invite a user by email.
pub async fn invite_member(
    State(state): State<AppState>,
    AuthUser(_user_id): AuthUser,
    Path(team_id): Path<String>,
    Json(req): Json<InviteMemberReq>,
) -> Result<Json<InviteMemberResp>, AppError> {
    // Look up the invited user by email (scan users table)
    let conn = state.db.conn();
    let invited_uid: Option<String> = conn
        .query_row(
            "SELECT id FROM users WHERE email=?1",
            rusqlite::params![req.email],
            |row| row.get(0),
        )
        .ok();
    drop(conn);

    if let Some(ref uid) = invited_uid {
        // Add member to team metadata
        let meta_key = format!("team_meta:{team_id}");
        if let Some(record) = state.db.get_vault(&_user_id, &meta_key) {
            let mut meta: serde_json::Value =
                serde_json::from_str(&record.facts_json).unwrap_or_default();
            if let Some(arr) = meta["members"].as_array_mut() {
                if !arr.iter().any(|v| v.as_str() == Some(uid)) {
                    arr.push(serde_json::json!(uid));
                }
            }
            state
                .db
                .upsert_vault(&_user_id, &meta_key, &meta.to_string())
                .map_err(|e| AppError(anyhow!(e)))?;
        }
    }

    Ok(Json(InviteMemberResp {
        ok: invited_uid.is_some(),
        invited_user_id: invited_uid,
    }))
}

/// POST /team/:team_id/vault — push facts to team shared vault.
pub async fn push_team_vault(
    State(state): State<AppState>,
    AuthUser(user_id): AuthUser,
    Path(team_id): Path<String>,
    Json(req): Json<TeamVaultPushReq>,
) -> Result<Json<TeamVaultPushResp>, AppError> {
    let key = team_vault_key(&team_id, &req.project_hash);
    let accepted = req.facts.len();
    let json = serde_json::to_string(&req.facts).map_err(|e| AppError(anyhow!(e)))?;
    let version = state
        .db
        .upsert_vault(&user_id, &key, &json)
        .map_err(|e| AppError(anyhow!(e)))?;
    Ok(Json(TeamVaultPushResp {
        accepted,
        server_version: version as u64,
    }))
}

/// GET /team/:team_id/vault/:project_hash — pull team shared vault.
pub async fn pull_team_vault(
    State(state): State<AppState>,
    AuthUser(user_id): AuthUser,
    Path((team_id, project_hash)): Path<(String, String)>,
) -> Result<Json<TeamVaultPullResp>, AppError> {
    let key = team_vault_key(&team_id, &project_hash);
    match state.db.get_vault(&user_id, &key) {
        Some(r) => {
            let facts: Vec<MemoFact> = serde_json::from_str(&r.facts_json).unwrap_or_default();
            Ok(Json(TeamVaultPullResp {
                facts,
                server_version: r.server_version as u64,
            }))
        }
        None => Ok(Json(TeamVaultPullResp {
            facts: vec![],
            server_version: 0,
        })),
    }
}