terraphim_middleware 1.16.33

Terraphim middleware for searching haystacks
Documentation
use crate::indexer::IndexMiddleware;
use crate::Result;
use async_trait::async_trait;
use reqwest::Client;
use terraphim_config::Haystack;
use terraphim_persistence::Persistable;
use terraphim_types::{Document, DocumentType, Index};

#[derive(Debug, Clone)]
pub struct ClickUpHaystackIndexer {
    client: Client,
}

impl Default for ClickUpHaystackIndexer {
    fn default() -> Self {
        Self {
            client: Client::new(),
        }
    }
}

impl ClickUpHaystackIndexer {
    /// Normalize document ID to match persistence layer expectations
    fn normalize_document_id(&self, original_id: &str) -> String {
        // Create a dummy document to access the normalize_key method
        let dummy_doc = Document {
            id: "dummy".to_string(),
            title: "dummy".to_string(),
            body: "dummy".to_string(),
            url: "dummy".to_string(),
            description: None,
            summarization: None,
            stub: None,
            tags: None,
            rank: None,
            source_haystack: None,
            doc_type: DocumentType::KgEntry,
            synonyms: None,
            route: None,
            priority: None,
        };
        dummy_doc.normalize_key(original_id)
    }

    fn map_task_value_to_document(&self, t: &serde_json::Value) -> Option<Document> {
        let id = t.get("id").and_then(|v| v.as_str())?.to_string();
        let title = t.get("name").and_then(|v| v.as_str())?.to_string();
        let description = t
            .get("text_content")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());

        let url = format!("https://app.clickup.com/t/{}", id);
        let original_id = format!("clickup-task-{}", id);
        let normalized_id = self.normalize_document_id(&original_id);
        Some(Document {
            id: normalized_id,
            url,
            title,
            body: description.clone().unwrap_or_default(),
            description,
            summarization: None,
            stub: None,
            tags: Some(vec!["clickup".to_string(), "task".to_string()]),
            rank: None,
            source_haystack: None,
            doc_type: DocumentType::KgEntry,
            synonyms: None,
            route: None,
            priority: None,
        })
    }
}

#[async_trait]
impl IndexMiddleware for ClickUpHaystackIndexer {
    fn index(
        &self,
        needle: &str,
        haystack: &Haystack,
    ) -> impl std::future::Future<Output = Result<Index>> + Send {
        let client = self.client.clone();
        let query = needle.to_string();
        let extras = haystack.get_extra_parameters().clone();
        async move {
            let token = std::env::var("CLICKUP_API_TOKEN").unwrap_or_default();
            if token.is_empty() {
                log::warn!("CLICKUP_API_TOKEN not set; returning empty index");
                return Ok(Index::default());
            }

            // Resolve search scope
            let team_id = extras
                .get("team_id")
                .cloned()
                .or_else(|| std::env::var("CLICKUP_TEAM_ID").ok());
            let list_id = extras.get("list_id").cloned();

            // Optional flags
            let include_closed = parse_bool_param(extras.get("include_closed"), false);
            let subtasks = parse_bool_param(extras.get("subtasks"), true);
            let page = extras
                .get("page")
                .cloned()
                .unwrap_or_else(|| "0".to_string());

            // Prefer universal search if available, otherwise fallback to list-based search via task endpoint
            let mut documents: Vec<Document> = Vec::new();

            if let Some(list) = list_id.clone() {
                if let Ok(mut docs) = search_clickup_list(
                    &client,
                    &token,
                    &list,
                    &query,
                    include_closed,
                    subtasks,
                    self,
                )
                .await
                {
                    documents.append(&mut docs);
                }
            } else if let Some(team) = team_id.clone() {
                if let Ok(mut docs) = search_clickup_universal(
                    &client,
                    &token,
                    &team,
                    &query,
                    &page,
                    include_closed,
                    subtasks,
                    self,
                )
                .await
                {
                    documents.append(&mut docs);
                }
            }

            // Deduplicate by id
            let mut index = Index::new();
            for doc in documents {
                index.insert(doc.id.clone(), doc);
            }
            Ok(index)
        }
    }
}

#[allow(clippy::too_many_arguments)]
async fn search_clickup_universal(
    client: &Client,
    token: &str,
    team_id: &str,
    query: &str,
    page: &str,
    include_closed: bool,
    subtasks: bool,
    indexer: &ClickUpHaystackIndexer,
) -> Result<Vec<Document>> {
    // GET /api/v2/team/{team_id}/task
    let url = format!("https://api.clickup.com/api/v2/team/{}/task", team_id);
    let params: Vec<(&str, String)> = vec![
        ("page", page.to_string()),
        ("order_by", "relevance".to_string()),
        ("reverse", "false".to_string()),
        ("subtasks", subtasks.to_string()),
        ("include_closed", include_closed.to_string()),
        ("query", query.to_string()),
    ];
    let resp = client
        .get(&url)
        .query(&params)
        .header("Authorization", token)
        .send()
        .await?;

    if !resp.status().is_success() {
        log::warn!("ClickUp search failed: {}", resp.status());
        return Ok(Vec::new());
    }

    // The response for tasks is an object with "tasks": [...]
    let json_value = resp.json::<serde_json::Value>().await?;
    let mut results: Vec<Document> = Vec::new();
    if let Some(tasks) = json_value.get("tasks").and_then(|v| v.as_array()) {
        for t in tasks {
            if let Some(doc) = indexer.map_task_value_to_document(t) {
                results.push(doc);
            }
        }
    }

    Ok(results)
}

async fn search_clickup_list(
    client: &Client,
    token: &str,
    list_id: &str,
    query: &str,
    include_closed: bool,
    subtasks: bool,
    indexer: &ClickUpHaystackIndexer,
) -> Result<Vec<Document>> {
    // GET /api/v2/list/{list_id}/task
    let url = format!("https://api.clickup.com/api/v2/list/{}/task", list_id);
    let params: Vec<(&str, String)> = vec![
        ("archived", include_closed.to_string()),
        ("subtasks", subtasks.to_string()),
        ("order_by", "relevance".to_string()),
        ("reverse", "false".to_string()),
        ("search", query.to_string()),
    ];
    let resp = client
        .get(&url)
        .query(&params)
        .header("Authorization", token)
        .send()
        .await?;

    if !resp.status().is_success() {
        log::warn!("ClickUp list search failed: {}", resp.status());
        return Ok(Vec::new());
    }

    let json_value = resp.json::<serde_json::Value>().await?;
    let mut results: Vec<Document> = Vec::new();
    if let Some(tasks) = json_value.get("tasks").and_then(|v| v.as_array()) {
        for t in tasks {
            if let Some(doc) = indexer.map_task_value_to_document(t) {
                results.push(doc);
            }
        }
    }

    Ok(results)
}

fn parse_bool_param(val: Option<&String>, default_value: bool) -> bool {
    val.and_then(|s| s.parse::<bool>().ok())
        .unwrap_or(default_value)
}