use std::collections::HashMap; use chrono::{DateTime, Utc}; use rust_xlsxwriter::{Format, Workbook, XlsxError}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::{AppError, AppResult}; /// Um valor de contato bruto, com o vínculo (pessoal/comercial) associado, /// usado para decidir o que aparece nas colunas de exportação. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContactValue { pub value: String, pub relationship: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExportFormat { Csv, Xlsx, } impl ExportFormat { pub fn parse(value: Option<&str>) -> Self { match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() { Some("csv") => ExportFormat::Csv, _ => ExportFormat::Xlsx, } } pub fn content_type(self) -> &'static str { match self { ExportFormat::Csv => "text/csv; charset=utf-8", ExportFormat::Xlsx => { "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" } } } pub fn filename(self) -> &'static str { match self { ExportFormat::Csv => "contatos.csv", ExportFormat::Xlsx => "contatos.xlsx", } } } pub struct ExportColumn { pub key: &'static str, pub header: &'static str, } /// Colunas de dados básicos do entrevistado, na ordem de exibição. pub const BASIC_FIELD_COLUMNS: &[ExportColumn] = &[ ExportColumn { key: "name", header: "Nome", }, ExportColumn { key: "createdAt", header: "Data", }, ExportColumn { key: "category", header: "Categoria", }, ExportColumn { key: "profession", header: "Profissão", }, ExportColumn { key: "description", header: "Bio", }, ]; /// Colunas de tipo de contato disponíveis para exportação, na ordem de exibição. pub const CONTACT_TYPE_COLUMNS: &[ExportColumn] = &[ ExportColumn { key: "email", header: "E-mail", }, ExportColumn { key: "phone", header: "Telefone", }, ExportColumn { key: "whatsapp", header: "WhatsApp", }, ExportColumn { key: "instagram", header: "Instagram", }, ExportColumn { key: "linkedin", header: "LinkedIn", }, ExportColumn { key: "website", header: "Site", }, ExportColumn { key: "facebook", header: "Facebook", }, ExportColumn { key: "tiktok", header: "TikTok", }, ExportColumn { key: "x", header: "X", }, ExportColumn { key: "telegram", header: "Telegram", }, ExportColumn { key: "youtube", header: "YouTube", }, ExportColumn { key: "other", header: "Outro", }, ]; /// Colunas "inteligentes" calculadas pela IA a partir dos contatos verificados. pub const SMART_FIELD_COLUMNS: &[ExportColumn] = &[ ExportColumn { key: "bestEmail", header: "Melhor e-mail", }, ExportColumn { key: "bestPhone", header: "Melhor telefone", }, ]; fn all_columns() -> impl Iterator { BASIC_FIELD_COLUMNS .iter() .chain(CONTACT_TYPE_COLUMNS.iter()) .chain(SMART_FIELD_COLUMNS.iter()) } const PHONE_LIKE: [&str; 2] = ["phone", "whatsapp"]; /// Analisa a lista de colunas pedida (separada por vírgula). `None` ou vazio /// significa "todas as colunas disponíveis". pub fn parse_columns(raw: Option<&str>) -> Vec<&'static str> { let requested = raw .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| { value .split(',') .map(|part| part.trim().to_owned()) .collect::>() }); match requested { None => all_columns().map(|column| column.key).collect(), Some(requested) => all_columns() .filter(|column| requested.iter().any(|value| value == column.key)) .map(|column| column.key) .collect(), } } #[derive(Debug, Clone, Serialize)] pub struct ExportRow { pub interviewee_id: Uuid, pub display_name: String, pub category: Option, pub profession: Option, pub description: String, pub created_at: DateTime, pub best_email: Option, pub best_phone: Option, pub by_type: HashMap>, } /// Formata uma data no padrão `dd/mm/aaaa`. pub fn format_date_br(value: DateTime) -> String { value.format("%d/%m/%Y").to_string() } /// Formata um telefone no padrão `5500000000000`: apenas dígitos, sempre com /// o código do país. Números de 10 ou 11 dígitos (DDD + número, sem código de /// país) recebem o prefixo `55` do Brasil; os demais são devolvidos como os /// dígitos originais, sem inventar um código de país que não veio no dado. pub fn format_phone_br(raw: &str) -> String { let digits: String = raw.chars().filter(char::is_ascii_digit).collect(); match digits.len() { 10 | 11 => format!("55{digits}"), _ => digits, } } fn format_value(column_key: &str, value: &str) -> String { if PHONE_LIKE.contains(&column_key) { format_phone_br(value) } else { value.to_owned() } } fn field_cell(row: &ExportRow, key: &str) -> String { match key { "name" => row.display_name.clone(), "createdAt" => format_date_br(row.created_at), "category" => row.category.clone().unwrap_or_default(), "profession" => row.profession.clone().unwrap_or_default(), "description" => row.description.clone(), "bestEmail" => row.best_email.clone().unwrap_or_default(), "bestPhone" => row .best_phone .as_deref() .map(format_phone_br) .unwrap_or_default(), _ => String::new(), } } /// Colunas de canal onde vale a pena listar todos os valores encontrados /// (comercial e pessoal). As demais colunas de rede social mostram apenas o /// contato pessoal, e no máximo um valor, para evitar linhas comerciais ou /// duplicadas nas redes sociais. const AGGREGATE_ALL_RELATIONSHIPS: [&str; 2] = ["email", "phone"]; fn contact_cell(row: &ExportRow, key: &str) -> String { let values = row.by_type.get(key).map(Vec::as_slice).unwrap_or_default(); if AGGREGATE_ALL_RELATIONSHIPS.contains(&key) { values .iter() .map(|entry| format_value(key, &entry.value)) .collect::>() .join("; ") } else { values .iter() .find(|entry| entry.relationship == "personal") .map(|entry| format_value(key, &entry.value)) .unwrap_or_default() } } fn build_table(rows: &[ExportRow], columns: &[&str]) -> (Vec, Vec>) { let headers = columns .iter() .map(|key| { all_columns() .find(|column| column.key == *key) .map(|column| column.header) .unwrap_or(key) .to_owned() }) .collect(); let is_contact_column = |key: &str| CONTACT_TYPE_COLUMNS.iter().any(|column| column.key == key); let body = rows .iter() .map(|row| { columns .iter() .map(|key| { if is_contact_column(key) { contact_cell(row, key) } else { field_cell(row, key) } }) .collect::>() }) .collect(); (headers, body) } pub fn build(rows: &[ExportRow], columns: &[&str], format: ExportFormat) -> AppResult> { let (headers, body) = build_table(rows, columns); match format { ExportFormat::Csv => build_csv(&headers, &body), ExportFormat::Xlsx => build_xlsx(&headers, &body), } } fn build_csv(headers: &[String], rows: &[Vec]) -> AppResult> { let mut writer = csv::WriterBuilder::new() .delimiter(b';') .from_writer(Vec::new()); writer.write_record(headers).map_err(csv_error)?; for row in rows { writer.write_record(row).map_err(csv_error)?; } let body = writer.into_inner().map_err(|error| { AppError::Validation(format!("erro ao gerar csv: {}", error.into_error())) })?; let mut buffer = vec![0xEF, 0xBB, 0xBF]; buffer.extend(body); Ok(buffer) } fn build_xlsx(headers: &[String], rows: &[Vec]) -> AppResult> { let mut workbook = Workbook::new(); let header_format = Format::new().set_bold(); let sheet = workbook.add_worksheet(); sheet.set_name("Contatos").map_err(xlsx_error)?; for (col, header) in headers.iter().enumerate() { sheet .write_string_with_format(0, col as u16, header, &header_format) .map_err(xlsx_error)?; } for (row_index, row) in rows.iter().enumerate() { for (col, value) in row.iter().enumerate() { sheet .write_string((row_index + 1) as u32, col as u16, value) .map_err(xlsx_error)?; } } sheet.autofit(); workbook.save_to_buffer().map_err(xlsx_error) } fn csv_error(error: csv::Error) -> AppError { AppError::Validation(format!("erro ao gerar csv: {error}")) } fn xlsx_error(error: XlsxError) -> AppError { AppError::Validation(format!("erro ao gerar xlsx: {error}")) } #[cfg(test)] mod tests { use super::*; #[test] fn keeps_phone_that_already_has_country_code() { assert_eq!(format_phone_br("+5521971716144"), "5521971716144"); } #[test] fn adds_country_code_to_eleven_digit_local_phone() { assert_eq!(format_phone_br("21971716144"), "5521971716144"); } #[test] fn adds_country_code_to_ten_digit_local_phone() { assert_eq!(format_phone_br("1132224455"), "551132224455"); } #[test] fn only_requested_columns_are_kept_in_order() { let columns = parse_columns(Some("linkedin,email,unknown")); assert_eq!(columns, vec!["email", "linkedin"]); } #[test] fn no_columns_filter_returns_all() { let columns = parse_columns(None); assert_eq!( columns.len(), BASIC_FIELD_COLUMNS.len() + CONTACT_TYPE_COLUMNS.len() + SMART_FIELD_COLUMNS.len() ); } #[test] fn field_columns_can_be_requested_alongside_contact_columns() { let columns = parse_columns(Some("name,description,email,bestEmail")); assert_eq!(columns, vec!["name", "description", "email", "bestEmail"]); } fn sample_row(by_type: HashMap>) -> ExportRow { ExportRow { interviewee_id: Uuid::nil(), display_name: "Ana".into(), category: None, profession: None, description: String::new(), created_at: Utc::now(), best_email: None, best_phone: None, by_type, } } #[test] fn social_columns_keep_only_the_personal_value() { let row = sample_row(HashMap::from([( "instagram".to_string(), vec![ ContactValue { value: "comercial.ig".into(), relationship: "commercial".into(), }, ContactValue { value: "pessoal.ig".into(), relationship: "personal".into(), }, ContactValue { value: "pessoal2.ig".into(), relationship: "personal".into(), }, ], )])); assert_eq!(contact_cell(&row, "instagram"), "pessoal.ig"); } #[test] fn social_column_is_blank_without_a_personal_contact() { let row = sample_row(HashMap::from([( "linkedin".to_string(), vec![ContactValue { value: "empresa.li".into(), relationship: "commercial".into(), }], )])); assert_eq!(contact_cell(&row, "linkedin"), ""); } #[test] fn email_and_phone_columns_aggregate_every_relationship() { let row = sample_row(HashMap::from([( "email".to_string(), vec![ ContactValue { value: "comercial@x.com".into(), relationship: "commercial".into(), }, ContactValue { value: "pessoal@x.com".into(), relationship: "personal".into(), }, ], )])); assert_eq!(contact_cell(&row, "email"), "comercial@x.com; pessoal@x.com"); } }