Files
leadcast/backend/src/export.rs
T
2026-07-26 21:12:38 -03:00

442 lines
13 KiB
Rust

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<Item = &'static ExportColumn> {
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::<Vec<_>>()
});
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<String>,
pub profession: Option<String>,
pub description: String,
pub created_at: DateTime<Utc>,
pub best_email: Option<String>,
pub best_phone: Option<String>,
pub by_type: HashMap<String, Vec<ContactValue>>,
}
/// Formata uma data no padrão `dd/mm/aaaa`.
pub fn format_date_br(value: DateTime<Utc>) -> 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::<Vec<_>>()
.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<String>, Vec<Vec<String>>) {
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::<Vec<_>>()
})
.collect();
(headers, body)
}
pub fn build(rows: &[ExportRow], columns: &[&str], format: ExportFormat) -> AppResult<Vec<u8>> {
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<String>]) -> AppResult<Vec<u8>> {
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<String>]) -> AppResult<Vec<u8>> {
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<String, Vec<ContactValue>>) -> 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");
}
}