AkurAI Build
Menu

Public repository / main

AkurAI-CRM / crates/crm-api/src/handlers/search.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};

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
}

fn json_values_contain(val: &akurai_json::Value, term: &str) -> bool {
    match val {
        akurai_json::Value::Str(s) => s.to_lowercase().contains(term),
        akurai_json::Value::Object(pairs) => {
            pairs.iter().any(|(_, v)| json_values_contain(v, term))
        }
        akurai_json::Value::Array(items) => items.iter().any(|v| json_values_contain(v, term)),
        _ => false,
    }
}

pub fn search_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 search_term = query
            .split('&')
            .find_map(|pair| {
                let mut parts = pair.splitn(2, '=');
                if parts.next()? == "q" {
                    parts.next().map(|v| url_decode(v).to_lowercase())
                } else {
                    None
                }
            })
            .unwrap_or_default();

        if search_term.is_empty() {
            return json_response(Value::Object(vec![
                ("query".into(), Value::Str("".into())),
                ("results".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 tenant = match graph::authorized_tenant(req, &mut db) {
            Ok(tenant) => tenant,
            Err(error) => return error.response(),
        };
        let mut results = Vec::new();

        let entity_prefixes = [
            "people:",
            "companies:",
            "opportunities:",
            "tasks:",
            "notes:",
        ];
        for prefix_str in &entity_prefixes {
            let prefix = prefix_str.as_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(""))
                    {
                        if json.get("tenantId").and_then(Value::as_str) == Some(&tenant)
                            && json_values_contain(&json, &search_term)
                        {
                            results.push(Value::Object(vec![
                                (
                                    "entity".into(),
                                    Value::Str(prefix_str.trim_end_matches(':').into()),
                                ),
                                ("record".into(), json),
                            ]));
                        }
                    }
                }
            }
        }

        json_response(Value::Object(vec![
            ("query".into(), Value::Str(search_term)),
            ("results".into(), Value::Array(results)),
        ]))
    })
}