442 lines
13 KiB
Rust
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");
|
|
}
|
|
}
|