AkurAI Build
Menu

Public repository / fce146c19e4ee44321ed964db4111dca7d75cc8b

AkurAI-IDP / src/routes/admin/tenants.rs

Latest change e1794828e5fae9a8545a66e77b2e8200cf8c090b - feat: add organization lifecycle telemetry by Ólafur Búi Ólafsson

use axum::{
    Extension, Json, Router,
    extract::Path,
    http::StatusCode,
    response::IntoResponse,
    routing::{get, patch},
};
use serde::{Deserialize, Serialize};
use serde_json::json;

use crate::db::with_db;
use crate::lib::audit;
use crate::lib::crypto::generate_secure_token;
use crate::lib::telemetry::OrganizationTelemetry;

#[derive(Serialize)]
pub struct TenantRow {
    pub id: String,
    pub name: String,
    pub slug: String,
    pub domain: Option<String>,
    pub created_at: i64,
}

#[derive(Deserialize)]
pub struct CreateTenant {
    pub name: String,
    pub slug: String,
    pub domain: Option<String>,
}

#[derive(Deserialize)]
pub struct UpdateTenant {
    pub name: Option<String>,
    pub slug: Option<String>,
    pub domain: Option<String>,
}

pub fn router() -> Router {
    Router::new()
        .route("/", get(list_tenants).post(create_tenant))
        .route("/{id}", patch(update_tenant).delete(delete_tenant))
}

async fn list_tenants() -> impl IntoResponse {
    let result = with_db(|conn| {
        let mut stmt = conn.prepare("SELECT id, name, slug, domain, created_at FROM tenants")?;
        let rows = stmt.query_map([], |row| {
            Ok(TenantRow {
                id: row.get(0)?,
                name: row.get(1)?,
                slug: row.get(2)?,
                domain: row.get(3)?,
                created_at: row.get(4)?,
            })
        })?;
        let mut tenants = Vec::new();
        for r in rows {
            tenants.push(r?);
        }
        Ok::<_, rusqlite::Error>(tenants)
    });

    match result {
        Ok(tenants) => Json(json!(tenants)).into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({"error": e.to_string()})),
        )
            .into_response(),
    }
}
async fn create_tenant(
    telemetry: Option<Extension<OrganizationTelemetry>>,
    Json(body): Json<CreateTenant>,
) -> impl IntoResponse {
    let id = generate_secure_token(16);
    let now = chrono::Utc::now().timestamp();

    let result = with_db(|conn| {
        let tx = conn.unchecked_transaction()?;
        tx.execute(
            "INSERT INTO tenants (id, name, slug, domain, created_at)
             VALUES (?1, ?2, ?3, ?4, ?5)",
            rusqlite::params![id, body.name, body.slug, body.domain, now],
        )?;
        tx.execute(
            "INSERT INTO organizations
             (id, display_name, slug, domain, created_at, updated_at)
             VALUES (?1, ?2, ?3, ?4, ?5, ?5)",
            rusqlite::params![id, body.name, body.slug, body.domain, now],
        )?;
        tx.commit()
    });

    match result {
        Ok(_) => {
            if let Some(Extension(telemetry)) = telemetry {
                telemetry.organization_created(&id, now);
                telemetry.active_member_count_after_organization_created(&id, now);
            }
            audit::log_audit(
                Some(&id),
                None,
                audit::TENANT_CREATED,
                None,
                Some(&json!({"name": body.name, "slug": body.slug})),
            );
            (
                StatusCode::CREATED,
                Json(json!({"id": id, "name": body.name, "slug": body.slug})),
            )
                .into_response()
        }
        Err(e) => {
            let msg = e.to_string();
            if msg.contains("UNIQUE") {
                (
                    StatusCode::CONFLICT,
                    Json(json!({"error": "tenant with this slug already exists"})),
                )
                    .into_response()
            } else {
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    Json(json!({"error": msg})),
                )
                    .into_response()
            }
        }
    }
}

async fn update_tenant(
    Path(id): Path<String>,
    Json(body): Json<UpdateTenant>,
) -> impl IntoResponse {
    let result: Result<usize, rusqlite::Error> = with_db(|conn| {
        let mut sets = Vec::new();
        let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
        let mut idx = 1u32;

        if let Some(ref name) = body.name {
            sets.push(format!("name = ?{idx}"));
            params.push(Box::new(name.clone()));
            idx += 1;
        }
        if let Some(ref slug) = body.slug {
            sets.push(format!("slug = ?{idx}"));
            params.push(Box::new(slug.clone()));
            idx += 1;
        }
        if let Some(ref domain) = body.domain {
            sets.push(format!("domain = ?{idx}"));
            params.push(Box::new(domain.clone()));
            idx += 1;
        }

        if sets.is_empty() {
            return Ok(0);
        }

        let sql = format!("UPDATE tenants SET {} WHERE id = ?{idx}", sets.join(", "));
        params.push(Box::new(id.clone()));

        let params_refs: Vec<&dyn rusqlite::types::ToSql> =
            params.iter().map(|p| p.as_ref()).collect();
        let changed = conn.execute(&sql, params_refs.as_slice())?;
        if changed > 0 {
            conn.execute(
                "UPDATE organizations
                 SET display_name = COALESCE(?1, display_name),
                     slug = COALESCE(?2, slug),
                     domain = COALESCE(?3, domain),
                     updated_at = ?4
                 WHERE id = ?5",
                rusqlite::params![
                    body.name,
                    body.slug,
                    body.domain,
                    chrono::Utc::now().timestamp(),
                    id
                ],
            )?;
        }
        Ok(changed)
    });

    match result {
        Ok(0) => (
            StatusCode::NOT_FOUND,
            Json(json!({"error": "tenant not found or no changes"})),
        )
            .into_response(),
        Ok(_) => {
            audit::log_audit(Some(&id), None, audit::TENANT_UPDATED, None, None);
            Json(json!({"ok": true})).into_response()
        }
        Err(e) => {
            let msg = e.to_string();
            if msg.contains("UNIQUE") {
                (
                    StatusCode::CONFLICT,
                    Json(json!({"error": "slug already taken"})),
                )
                    .into_response()
            } else {
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    Json(json!({"error": msg})),
                )
                    .into_response()
            }
        }
    }
}

async fn delete_tenant(Path(id): Path<String>) -> impl IntoResponse {
    let result: Result<usize, rusqlite::Error> = with_db(|conn| {
        let changed = conn.execute("DELETE FROM tenants WHERE id = ?1", [&id])?;
        if changed > 0 {
            conn.execute("DELETE FROM organizations WHERE id = ?1", [&id])?;
        }
        Ok(changed)
    });

    match result {
        Ok(0) => (
            StatusCode::NOT_FOUND,
            Json(json!({"error": "tenant not found"})),
        )
            .into_response(),
        Ok(_) => {
            audit::log_audit(Some(&id), None, audit::TENANT_DELETED, None, None);
            Json(json!({"ok": true})).into_response()
        }
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({"error": e.to_string()})),
        )
            .into_response(),
    }
}