1use crate::client::ClickUpClient;
2use crate::error::{ClickUpError, Result};
3use chrono::{Datelike, Utc};
4use serde::Deserialize;
5use serde_json::Value;
6use std::collections::HashMap;
30
31const FUZZY_THRESHOLD: f64 = 0.70; fn deserialize_id_flexible<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
37where
38 D: serde::Deserializer<'de>,
39{
40 use serde::de::{self, Deserialize};
41
42 let value = Value::deserialize(deserializer)?;
43 match value {
44 Value::String(s) => Ok(s),
45 Value::Number(n) => Ok(n.to_string()),
46 _ => Err(de::Error::custom("id must be string or number")),
47 }
48}
49#[allow(dead_code)]
50const MIN_HISTORICAL_CONFIDENCE: f64 = 0.5;
51
52#[derive(Debug, Clone)]
53pub struct FolderSearchResult {
54 pub folder_id: String,
55 pub folder_name: String,
56 pub list_id: Option<String>,
57 pub list_name: Option<String>,
58 pub confidence: f64,
59 pub search_method: SearchMethod,
60}
61
62#[derive(Debug, Clone, PartialEq)]
63pub enum SearchMethod {
64 ExactMatch, FuzzyMatch, SemanticMatch, HistoricalMatch, NotFound, }
70
71#[derive(Debug, Deserialize)]
73struct ClickUpFoldersResponse {
74 folders: Vec<ClickUpFolder>,
75}
76
77#[derive(Debug, Deserialize, Clone)]
78struct ClickUpFolder {
79 #[serde(deserialize_with = "deserialize_id_flexible")]
80 id: String,
81 name: String,
82 #[allow(dead_code)]
83 lists: Option<Vec<ClickUpList>>,
84}
85
86#[derive(Debug, Deserialize, Clone)]
87struct ClickUpList {
88 #[serde(deserialize_with = "deserialize_id_flexible")]
89 #[allow(dead_code)]
90 id: String,
91 #[allow(dead_code)]
92 name: String,
93}
94
95#[derive(Debug, Deserialize)]
97struct ClickUpTasksResponse {
98 tasks: Vec<ClickUpTask>,
99}
100
101#[derive(Debug, Deserialize)]
102struct ClickUpTask {
103 #[serde(deserialize_with = "deserialize_id_flexible")]
104 #[allow(dead_code)]
105 id: String,
106 #[allow(dead_code)]
107 name: Option<String>,
108 #[allow(dead_code)]
109 folder: Option<ClickUpTaskFolder>,
110 #[allow(dead_code)]
111 list: Option<ClickUpTaskList>,
112 #[allow(dead_code)]
113 custom_fields: Option<Vec<ClickUpCustomField>>,
114}
115
116#[derive(Debug, Deserialize)]
117struct ClickUpTaskFolder {
118 #[serde(deserialize_with = "deserialize_id_flexible")]
119 #[allow(dead_code)]
120 id: String,
121 #[allow(dead_code)]
122 name: String,
123}
124
125#[derive(Debug, Deserialize)]
126struct ClickUpTaskList {
127 #[allow(dead_code)]
128 #[serde(deserialize_with = "deserialize_id_flexible")]
129 id: String,
130 #[allow(dead_code)]
131 name: String,
132}
133
134#[derive(Debug, Deserialize)]
135pub struct ClickUpCustomField {
136 #[allow(dead_code)]
137 id: String,
138 #[allow(dead_code)]
139 value: Option<serde_json::Value>,
140}
141
142#[derive(Debug)]
143pub struct SmartFolderFinder {
144 client: ClickUpClient,
145 workspace_id: String,
146 cache: HashMap<String, FolderSearchResult>,
147}
148
149impl SmartFolderFinder {
150 pub fn new(client: ClickUpClient, workspace_id: String) -> Self {
152 Self {
153 client,
154 workspace_id,
155 cache: HashMap::new(),
156 }
157 }
158
159 pub fn from_token(api_token: String, workspace_id: String) -> Result<Self> {
161 let client = ClickUpClient::new(api_token)?;
162 Ok(Self::new(client, workspace_id))
163 }
164
165 pub async fn find_folder_for_client(
178 &mut self,
179 client_name: &str,
180 ) -> Result<Option<FolderSearchResult>> {
181 let normalized_name = Self::normalize_name(client_name);
182
183 tracing::info!(
184 "🔍 SmartFolderFinder: Buscando folder para '{}'",
185 client_name
186 );
187
188 if let Some(cached) = self.cache.get(&normalized_name) {
190 tracing::info!("✅ Encontrado em cache: folder_id={}", cached.folder_id);
191 return Ok(Some(cached.clone()));
192 }
193
194 match self.search_folders_via_api(&normalized_name).await {
196 Ok(Some(result)) => {
197 self.cache.insert(normalized_name.clone(), result.clone());
198 return Ok(Some(result));
199 }
200 Ok(None) => {
201 tracing::info!("⚠️ Não encontrado via API, tentando busca histórica...");
202 }
203 Err(e) => {
204 tracing::warn!(
205 "⚠️ Erro na busca via API: {}, tentando busca histórica...",
206 e
207 );
208 }
209 }
210
211 match self.search_historical_tasks(&normalized_name).await {
213 Ok(Some(result)) => {
214 self.cache.insert(normalized_name.clone(), result.clone());
215 return Ok(Some(result));
216 }
217 Ok(None) => {
218 tracing::warn!(
219 "⚠️ Cliente '{}' não encontrado (nem API, nem histórico)",
220 client_name
221 );
222 }
223 Err(e) => {
224 tracing::error!("❌ Erro na busca histórica: {}", e);
225 }
226 }
227
228 Ok(None)
230 }
231
232 pub async fn find_folder_by_extracted_value(
258 &mut self,
259 extracted_value: &str,
260 ) -> Result<Option<FolderSearchResult>> {
261 tracing::info!(
262 "🔍 SmartFolderFinder: Buscando folder usando valor extraído: '{}'",
263 extracted_value
264 );
265
266 self.find_folder_for_client(extracted_value).await
269 }
270
271 pub fn extract_custom_field_value(
282 custom_fields: &[ClickUpCustomField],
283 target_field_id: &str,
284 ) -> Option<String> {
285 for field in custom_fields {
286 if field.id == target_field_id {
287 if let Some(value) = &field.value {
288 match value {
290 serde_json::Value::String(s) => return Some(s.clone()),
291 serde_json::Value::Number(n) => return Some(n.to_string()),
292 serde_json::Value::Bool(b) => return Some(b.to_string()),
293 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
294 return Some(value.to_string());
295 }
296 serde_json::Value::Null => return None,
297 }
298 }
299 }
300 }
301 None
302 }
303
304 pub async fn find_folder_from_task_field(
324 &mut self,
325 custom_fields: Option<&Vec<ClickUpCustomField>>,
326 field_id: &str,
327 ) -> Result<Option<FolderSearchResult>> {
328 tracing::info!(
329 "🔍 Extraindo valor do campo '{}' para busca de folder",
330 field_id
331 );
332
333 if let Some(fields) = custom_fields {
334 if let Some(extracted_value) = Self::extract_custom_field_value(fields, field_id) {
335 tracing::info!("✅ Valor extraído: '{}'", extracted_value);
336 return self.find_folder_by_extracted_value(&extracted_value).await;
337 }
338 }
339
340 tracing::warn!("⚠️ Campo '{}' não encontrado ou vazio", field_id);
341 Ok(None)
342 }
343
344 async fn search_folders_via_api(
346 &self,
347 normalized_client: &str,
348 ) -> Result<Option<FolderSearchResult>> {
349 tracing::info!("📡 Buscando folders via API do ClickUp...");
350
351 let endpoint = format!("/team/{}/space", self.workspace_id);
356 let spaces: serde_json::Value = self.client.get_json(&endpoint).await?;
357
358 let spaces_array = spaces["spaces"].as_array().ok_or_else(|| {
359 ClickUpError::ValidationError("Campo 'spaces' não é array".to_string())
360 })?;
361
362 let mut all_folders = Vec::new();
364 for space in spaces_array {
365 let space_id = space["id"]
366 .as_str()
367 .ok_or_else(|| ClickUpError::ValidationError("Space sem ID".to_string()))?;
368
369 match self.fetch_folders_from_space(space_id).await {
370 Ok(folders) => all_folders.extend(folders),
371 Err(e) => {
372 tracing::warn!("⚠️ Erro ao buscar folders do space {}: {}", space_id, e);
373 }
374 }
375 }
376
377 tracing::info!("📁 Total de folders encontrados: {}", all_folders.len());
378
379 self.find_best_folder_match(normalized_client, &all_folders)
381 .await
382 }
383
384 async fn fetch_folders_from_space(&self, space_id: &str) -> Result<Vec<ClickUpFolder>> {
386 let endpoint = format!("/space/{}/folder", space_id);
387 let folders_response: ClickUpFoldersResponse = self.client.get_json(&endpoint).await?;
388 Ok(folders_response.folders)
389 }
390
391 async fn find_best_folder_match(
401 &self,
402 normalized_client: &str,
403 folders: &[ClickUpFolder],
404 ) -> Result<Option<FolderSearchResult>> {
405 let mut best_match: Option<(ClickUpFolder, f64, SearchMethod)> = None;
406
407 for folder in folders {
408 let normalized_folder = Self::normalize_name(&folder.name);
409
410 if normalized_folder == normalized_client {
412 tracing::info!("✅ Match exato: '{}'", folder.name);
413 best_match = Some((folder.clone(), 1.0, SearchMethod::ExactMatch));
414 break;
415 }
416
417 let similarity = strsim::jaro_winkler(normalized_client, &normalized_folder);
419
420 tracing::debug!(
421 " Comparando: '{}' vs '{}' → score: {:.3}",
422 normalized_client,
423 normalized_folder,
424 similarity
425 );
426
427 if similarity >= FUZZY_THRESHOLD {
428 if let Some((_, best_score, _)) = &best_match {
429 if similarity > *best_score {
430 best_match = Some((folder.clone(), similarity, SearchMethod::FuzzyMatch));
431 }
432 } else {
433 best_match = Some((folder.clone(), similarity, SearchMethod::FuzzyMatch));
434 }
435 }
436
437 if best_match.is_none()
440 || best_match
441 .as_ref()
442 .map(|(_, score, _)| *score)
443 .unwrap_or(0.0)
444 < 0.90
445 {
446 let client_tokens = Self::extract_name_tokens(normalized_client);
447 let folder_tokens = Self::extract_name_tokens(&normalized_folder);
448
449 if !client_tokens.is_empty() && !folder_tokens.is_empty() {
450 let matching_tokens = client_tokens
451 .iter()
452 .filter(|ct| {
453 folder_tokens
454 .iter()
455 .any(|ft| strsim::jaro_winkler(ct, ft) >= 0.90)
456 })
457 .count();
458
459 let token_score = matching_tokens as f64
460 / client_tokens.len().max(folder_tokens.len()) as f64;
461
462 if token_score >= 0.60 {
463 tracing::debug!(
465 " Token match: {}/{} tokens → score: {:.3}",
466 matching_tokens,
467 client_tokens.len().max(folder_tokens.len()),
468 token_score
469 );
470
471 if let Some((_, best_score, _)) = &best_match {
472 if token_score > *best_score {
473 best_match =
474 Some((folder.clone(), token_score, SearchMethod::FuzzyMatch));
475 }
476 } else {
477 best_match =
478 Some((folder.clone(), token_score, SearchMethod::FuzzyMatch));
479 }
480 }
481 }
482 }
483 }
484
485 if let Some((folder, score, method)) = best_match {
486 let (list_id, list_name) = self.find_or_create_current_month_list(&folder.id).await?;
488
489 Ok(Some(FolderSearchResult {
490 folder_id: folder.id,
491 folder_name: folder.name,
492 list_id: Some(list_id),
493 list_name: Some(list_name),
494 confidence: score,
495 search_method: method,
496 }))
497 } else {
498 Ok(None)
499 }
500 }
501
502 async fn search_historical_tasks(
511 &self,
512 normalized_client: &str,
513 ) -> Result<Option<FolderSearchResult>> {
514 tracing::info!(
515 "🕐 Buscando tarefas históricas para cliente = '{}' (funcionalidade descontinuada)",
516 normalized_client
517 );
518
519 let endpoint = format!(
522 "/team/{}/task?archived=false&subtasks=false&include_closed=true",
523 self.workspace_id
524 );
525 let tasks_response: ClickUpTasksResponse = self.client.get_json(&endpoint).await?;
526
527 tracing::info!(
528 "📋 Total de tarefas encontradas: {}",
529 tasks_response.tasks.len()
530 );
531
532 tracing::warn!(
535 "⚠️ Busca histórica descontinuada - campo 'Cliente Solicitante' removido do sistema"
536 );
537 Ok(None)
538
539 }
557
558 fn get_month_name_pt(&self, date: chrono::DateTime<Utc>) -> String {
560 let month = date.month();
561 let year = date.year();
562
563 let month_pt = match month {
564 1 => "JANEIRO",
565 2 => "FEVEREIRO",
566 3 => "MARÇO",
567 4 => "ABRIL",
568 5 => "MAIO",
569 6 => "JUNHO",
570 7 => "JULHO",
571 8 => "AGOSTO",
572 9 => "SETEMBRO",
573 10 => "OUTUBRO",
574 11 => "NOVEMBRO",
575 12 => "DEZEMBRO",
576 _ => "DESCONHECIDO",
577 };
578
579 format!("{} {}", month_pt, year)
580 }
581
582 async fn find_or_create_current_month_list(&self, folder_id: &str) -> Result<(String, String)> {
584 let now = Utc::now();
585 let month_name_pt = self.get_month_name_pt(now); let month_number = now.month();
587
588 tracing::info!("📅 Buscando lista do mês atual: '{}'", month_name_pt);
589
590 let endpoint = format!("/folder/{}", folder_id);
592 let folder: serde_json::Value = self.client.get_json(&endpoint).await?;
593
594 let months_pt = [
596 "janeiro",
597 "fevereiro",
598 "março",
599 "abril",
600 "maio",
601 "junho",
602 "julho",
603 "agosto",
604 "setembro",
605 "outubro",
606 "novembro",
607 "dezembro",
608 ];
609 let current_month_pt = months_pt[(month_number - 1) as usize];
610 let year_str = now.year().to_string();
611
612 if let Some(lists) = folder["lists"].as_array() {
614 for list in lists {
615 if let Some(name) = list["name"].as_str() {
616 let name_lower = name.to_lowercase();
617
618 if (name_lower.contains(current_month_pt)
620 || name_lower.contains(&now.format("%B").to_string().to_lowercase()))
621 && name_lower.contains(&year_str)
622 {
623 let list_id = list["id"].as_str().ok_or_else(|| {
624 ClickUpError::ValidationError("Lista sem ID".to_string())
625 })?;
626
627 tracing::info!("✅ Lista do mês encontrada: {} (id: {})", name, list_id);
628 return Ok((list_id.to_string(), name.to_string()));
629 }
630 }
631 }
632 }
633
634 tracing::info!("📝 Criando lista do mês: '{}'", month_name_pt);
636 self.create_list(folder_id, &month_name_pt).await
637 }
638
639 async fn create_list(&self, folder_id: &str, list_name: &str) -> Result<(String, String)> {
641 let endpoint = format!("/folder/{}/list", folder_id);
642 let payload = serde_json::json!({
643 "name": list_name,
644 "content": format!("Lista criada automaticamente para {}", list_name),
645 });
646
647 let list: serde_json::Value = self.client.post_json(&endpoint, &payload).await?;
648
649 let list_id = list["id"]
650 .as_str()
651 .ok_or_else(|| ClickUpError::ValidationError("Lista criada sem ID".to_string()))?;
652
653 tracing::info!(
654 "✅ Lista criada com sucesso: {} (id: {})",
655 list_name,
656 list_id
657 );
658
659 tracing::debug!("⏳ Aguardando 2s para custom fields serem configurados...");
661 tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
662
663 Ok((list_id.to_string(), list_name.to_string()))
664 }
665
666 pub fn normalize_name(name: &str) -> String {
668 use deunicode::deunicode;
669
670 let normalized = name.replace(['/', '\\', '|', '-'], " ");
672
673 deunicode(&normalized)
675 .to_lowercase()
676 .chars()
677 .filter(|c| c.is_alphanumeric() || c.is_whitespace())
678 .collect::<String>()
679 .split_whitespace()
680 .collect::<Vec<&str>>()
681 .join(" ")
682 }
683
684 fn extract_name_tokens(name: &str) -> Vec<String> {
687 Self::normalize_name(name)
688 .split_whitespace()
689 .filter(|token| token.len() > 2) .map(|s| s.to_string())
691 .collect()
692 }
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698
699 #[test]
700 fn test_normalize_name() {
701 assert_eq!(
702 SmartFolderFinder::normalize_name("Raphaela Spielberg"),
703 "raphaela spielberg"
704 );
705 assert_eq!(
706 SmartFolderFinder::normalize_name("José Muritiba (123)"),
707 "jose muritiba 123"
708 );
709 assert_eq!(
710 SmartFolderFinder::normalize_name("Gabriel Benarros!!!"),
711 "gabriel benarros"
712 );
713
714 assert_eq!(
716 SmartFolderFinder::normalize_name("Breno / Leticia"),
717 "breno leticia"
718 );
719 assert_eq!(
720 SmartFolderFinder::normalize_name("Leticia e Breno"),
721 "leticia e breno"
722 );
723 assert_eq!(
724 SmartFolderFinder::normalize_name("Carlos | Pedro"),
725 "carlos pedro"
726 );
727 assert_eq!(SmartFolderFinder::normalize_name("Ana-Paula"), "ana paula");
728 }
729
730 #[test]
731 fn test_extract_name_tokens() {
732 let tokens = SmartFolderFinder::extract_name_tokens("Breno / Leticia");
733 assert_eq!(tokens, vec!["breno", "leticia"]);
734
735 let tokens2 = SmartFolderFinder::extract_name_tokens("Leticia e Breno");
736 assert_eq!(tokens2, vec!["leticia", "breno"]);
737
738 let tokens3 = SmartFolderFinder::extract_name_tokens("José de Oliveira");
739 assert_eq!(tokens3, vec!["jose", "oliveira"]); }
741
742 #[test]
743 fn test_extract_custom_field_value() {
744 use serde_json::Value;
745
746 let custom_fields = vec![
748 ClickUpCustomField {
749 id: "info_1".to_string(),
750 value: Some(Value::String("Valor Info 1".to_string())),
751 },
752 ClickUpCustomField {
753 id: "info_2".to_string(),
754 value: Some(Value::String("João Silva".to_string())),
755 },
756 ClickUpCustomField {
757 id: "priority".to_string(),
758 value: Some(Value::Number(serde_json::Number::from(3))),
759 },
760 ClickUpCustomField {
761 id: "active".to_string(),
762 value: Some(Value::Bool(true)),
763 },
764 ClickUpCustomField {
765 id: "empty_field".to_string(),
766 value: None,
767 },
768 ];
769
770 assert_eq!(
772 SmartFolderFinder::extract_custom_field_value(&custom_fields, "info_2"),
773 Some("João Silva".to_string())
774 );
775
776 assert_eq!(
778 SmartFolderFinder::extract_custom_field_value(&custom_fields, "priority"),
779 Some("3".to_string())
780 );
781
782 assert_eq!(
784 SmartFolderFinder::extract_custom_field_value(&custom_fields, "active"),
785 Some("true".to_string())
786 );
787
788 assert_eq!(
790 SmartFolderFinder::extract_custom_field_value(&custom_fields, "inexistente"),
791 None
792 );
793
794 assert_eq!(
796 SmartFolderFinder::extract_custom_field_value(&custom_fields, "empty_field"),
797 None
798 );
799 }
800}