AkurAI Build
Menu

Public repository / main

AkurAI-CRM / crates/crm-api/src/handlers/timeline.rs

Latest change c7bf22ef78ad08f1f59b04d08e88d86e4ca91815 - feat: add tenant-safe account graph by Ólafur Búi Ólafsson

use crate::graph;
use crate::router_setup::{internal_error, json_response, upper_bound, CrmState};
use akurai_http::{Request, Response};
use akurai_json::Value;
use std::sync::{Arc, Mutex};

pub fn timeline_route(
    state: Arc<Mutex<CrmState>>,
) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
    Box::new(move |req: &Request| {
        let query = req.query.as_deref().unwrap_or("");
        let mut entity_type = String::new();
        let mut entity_id: u64 = 0;

        fn url_decode(s: &str) -> String {
            let mut result = String::new();
            let mut chars = s.chars();
            while let Some(c) = chars.next() {
                if c == '%' {
                    let hi = chars.next().and_then(|c| c.to_digit(16)).unwrap_or(0);
                    let lo = chars.next().and_then(|c| c.to_digit(16)).unwrap_or(0);
                    result.push(char::from((hi * 16 + lo) as u8));
                } else if c == '+' {
                    result.push(' ');
                } else {
                    result.push(c);
                }
            }
            result
        }

        for pair in query.split('&') {
            let mut parts = pair.splitn(2, '=');
            match parts.next() {
                Some("entityType") => entity_type = url_decode(parts.next().unwrap_or("")),
                Some("entityId") => {
                    entity_id = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0)
                }
                _ => {}
            }
        }

        if entity_type.is_empty() || entity_id == 0 {
            return json_response(Value::Object(vec![(
                "timeline".into(),
                Value::Array(vec![]),
            )]));
        }

        let state = match state.lock() {
            Ok(s) => s,
            Err(_) => return internal_error("lock poisoned"),
        };
        let mut db = match state.db.lock() {
            Ok(db) => db,
            Err(_) => return internal_error("db lock poisoned"),
        };
        let mut activities = Vec::new();
        let tenant = match graph::authorized_tenant(req, &mut db) {
            Ok(tenant) => tenant,
            Err(error) => return error.response(),
        };
        let collection = match entity_type.as_str() {
            "people" | "companies" | "opportunities" | "tasks" | "notes" => entity_type.as_str(),
            _ => return crate::router_setup::bad_request("invalid entity type"),
        };
        let mut key = format!("{collection}:").into_bytes();
        key.extend_from_slice(&entity_id.to_be_bytes());
        let record = match db.get(&key) {
            Ok(Some(bytes)) => akurai_json::parse(std::str::from_utf8(&bytes).unwrap_or("")).ok(),
            Ok(None) => None,
            Err(e) => return internal_error(&format!("storage: {e}")),
        };
        if record
            .as_ref()
            .and_then(|value| value.get("tenantId").and_then(Value::as_str))
            != Some(&tenant)
        {
            return crate::router_setup::error_response(403, "record belongs to another tenant");
        }

        let prefix = format!("timeline:{}:{}:", entity_type, entity_id).into_bytes();
        let end = upper_bound(&prefix);
        if let Ok(entries) = db.range(&prefix, &end) {
            for (_key_bytes, val_bytes) in entries {
                if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or(""))
                {
                    activities.push(json);
                }
            }
        }

        activities.reverse();

        json_response(Value::Object(vec![
            ("entityType".into(), Value::Str(entity_type)),
            ("entityId".into(), Value::Int(entity_id as i64)),
            ("timeline".into(), Value::Array(activities)),
        ]))
    })
}

pub fn record_timeline(
    db: &mut akurai_storage::BTree,
    entity_type: &str,
    entity_id: u64,
    action: &str,
    actor_id: Option<u64>,
) -> Result<(), String> {
    use std::time::{SystemTime, UNIX_EPOCH};
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs() as i64;

    use std::collections::hash_map::RandomState;
    use std::hash::{BuildHasher, Hasher};
    let rand_suffix = RandomState::new().build_hasher().finish() % 10000;
    let key = format!(
        "timeline:{}:{}:{}_{}",
        entity_type, entity_id, now, rand_suffix
    )
    .into_bytes();

    let record = Value::Object(vec![
        ("action".into(), Value::Str(action.into())),
        ("entityType".into(), Value::Str(entity_type.into())),
        ("entityId".into(), Value::Int(entity_id as i64)),
        ("actorId".into(), Value::Int(actor_id.unwrap_or(0) as i64)),
        ("createdAt".into(), Value::Int(now)),
    ]);

    let json_str = record.to_json();
    db.insert(&key, json_str.as_bytes())
        .map_err(|e| format!("timeline write: {e}"))?;
    db.commit().map_err(|e| format!("timeline commit: {e}"))?;

    Ok(())
}