From a604d63622da149b68db4134a76eb0ab065bb037 Mon Sep 17 00:00:00 2001 From: Matthieu Bessat Date: Wed, 20 Dec 2023 16:47:13 +0100 Subject: [PATCH] refactor: split Helloasso logic into separate file --- Cargo.lock | 21 ++ Cargo.toml | 2 + src/helloasso.rs | 333 ++++++++++++++++++++++++++++++++ src/main.rs | 485 ++++++++++++----------------------------------- 4 files changed, 482 insertions(+), 359 deletions(-) create mode 100644 src/helloasso.rs diff --git a/Cargo.lock b/Cargo.lock index 4235200..63a9813 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -604,6 +604,15 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +[[package]] +name = "email_address" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.33" @@ -694,6 +703,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fully_pub" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd8cb48eceb4e8b471af6a8e4e223cbe1286552791b9ab274512ba9cfd754df" +dependencies = [ + "quote", + "syn 2.0.38", +] + [[package]] name = "futures-channel" version = "0.3.28" @@ -1378,7 +1397,9 @@ dependencies = [ "chrono", "clap", "dotenvy", + "email_address", "envy", + "fully_pub", "phonenumber", "rand 0.8.5", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 0b7f5f8..cf5f252 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,5 @@ strum = { version = "0.25", features = ["derive"] } dotenvy = "0.15.7" rand = "0.8.5" phonenumber = "0.3.3" +email_address = "0.2" +fully_pub = "0.1.4" diff --git a/src/helloasso.rs b/src/helloasso.rs new file mode 100644 index 0000000..39862da --- /dev/null +++ b/src/helloasso.rs @@ -0,0 +1,333 @@ +use anyhow::{Context, Result, anyhow}; +use url::Url; +use serde::{Serialize, Deserialize}; +use fully_pub::fully_pub; + +use thiserror::Error; + +#[derive(Error, Debug)] +enum APIClientError { + #[error("Received non-normal status code from API")] + InvalidStatusCode +} + +#[fully_pub] +#[derive(Clone, Serialize, Deserialize, Debug)] +struct WebSession { + jwt: String +} + +pub enum LoginError { + TransportFailure(reqwest::Error) +} + +#[derive(Debug)] +#[fully_pub] +struct Client { + client: reqwest::Client, + base_url: Url, +} + +#[derive(Serialize, Debug)] +#[fully_pub] +struct LoginPayload { + email: String, + password: String +} + +impl Default for Client { + fn default() -> Self { + Client { + client: Client::get_base_client_builder() + .build() + .expect("reqwest client to be built"), + base_url: Url::parse("https://api.helloasso.com/v5/") + .expect("Valid helloasso API base URL") + } + } +} + +impl Client { + + fn get_base_client_builder() -> reqwest::ClientBuilder { + let mut default_headers = reqwest::header::HeaderMap::new(); + default_headers.insert("Accept", "application/json".parse().unwrap()); + + let proxy = reqwest::Proxy::https("https://localhost:8999").unwrap(); + reqwest::Client::builder() + .proxy(proxy) + .default_headers(default_headers) + } + + pub async fn login(&mut self, payload: LoginPayload) -> Result { + let mut login_commons_headers = reqwest::header::HeaderMap::new(); + login_commons_headers.insert( + "Origin", + "https://auth.helloasso.com".parse().expect("Header value to be OK") + ); + + let res = self.client.get(self.base_url.join("auth/antiforgerytoken")?) + .headers(login_commons_headers.clone()) + .send().await?; + let antiforgerytoken: String = res.json().await?; + + let res = self.client.post(self.base_url.join("auth/login")?) + .json(&payload) + .headers(login_commons_headers.clone()) + .header("x-csrf-token", antiforgerytoken) + .send() + .await?; + + if res.status() != 200 { + return Err(anyhow!("Unexpected status code from login")); + } + + fn get_jwt_from_cookies_headers(headers: &reqwest::header::HeaderMap) -> Option { + for (name_opt, value_raw) in headers { + let name = String::from(name_opt.as_str()); + if name.to_lowercase() != "set-cookie" { + continue + } + let value = String::from(value_raw.to_str().unwrap()); + if value.starts_with("tm5-HelloAsso") { + let jwt = value.split("tm5-HelloAsso=").nth(1)?.split(";").nth(0)?.trim().to_string(); + return Some(jwt); + } + } + None + } + + let jwt = get_jwt_from_cookies_headers(&res.headers()) + .context("Failed to find or parse JWT from login response")?; + + let session = WebSession { jwt }; + + Ok(self.authentified_client(session)) + } + + pub fn authentified_client(&self, session: WebSession) -> AuthentifiedClient { + AuthentifiedClient::new(self.base_url.clone(), session) + } +} + +#[derive(Debug, Clone)] +#[fully_pub] +struct AuthentifiedClient { + session: WebSession, + client: reqwest::Client, + base_url: Url +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[fully_pub] +struct PaginationMeta { + continuation_token: String, + page_index: u64, + page_size: u64, + total_count: u64, + total_pages: u64 +} + +#[derive(Debug, Serialize, Deserialize)] +struct PaginationCapsule { + data: serde_json::Value, + pagination: PaginationMeta +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[fully_pub] +struct CustomFieldAnswer { + answer: String, + id: u64, + name: String + // missing type, it's probably always TextInput, if not, serde will fail to parse +} + + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[fully_pub] +struct PayerUserDetails { + country: String, + email: String, + first_name: String, + last_name: String +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[fully_pub] +struct UserDetails { + first_name: String, + last_name: String +} + + +// #[derive(Debug, Serialize, Deserialize)] +// #[serde(rename_all = "camelCase")] +// struct OrderDetails { +// date: +// form_ +// } + +impl AuthentifiedClient { + /// each time we need to change the token, we will need to rebuild the client + pub fn new(base_url: Url, session: WebSession) -> Self { + let mut auth_headers = reqwest::header::HeaderMap::new(); + auth_headers.insert("Authorization", format!("Bearer {}", session.jwt).parse().unwrap()); + + AuthentifiedClient { + base_url, + session, + client: Client::get_base_client_builder() + .default_headers(auth_headers) + .build() + .expect("reqwest client to be built") + } + } + + pub async fn verify_auth(&self) -> Result { + let res = self.client + .get(self.base_url.join("agg/user")?) + .send().await?; + return Ok(res.status() == 200); + } + + pub async fn get_user_details(&self) -> Result { + let res = self.client + .get(self.base_url.join("agg/user")?) + .send().await?; + if res.status() != 200 { + return Err(APIClientError::InvalidStatusCode.into()); + } + let user_details: serde_json::Value = res.json().await?; + + Ok(user_details) + } + + async fn simple_fetch(&self, path: String) -> Result { + let res = self.client + .get(self.base_url.join(path.as_str())?) + .send().await?; + if res.status() != 200 { + return Err(APIClientError::InvalidStatusCode.into()); + } + let details: serde_json::Value = res.json().await?; + + Ok(details) + } + + pub async fn fetch_with_pagination(&self, path: String) -> Result> { + let mut data: Vec = vec![]; + let mut continuation_token: Option = None; + + loop { + let mut url = self.base_url.join(path.as_str())?; + if let Some(token) = &continuation_token { + url.query_pairs_mut().append_pair("continuationToken", token); + } + let res = self.client + .get(url) + .send().await?; + if res.status() != 200 { + return Err(APIClientError::InvalidStatusCode.into()); + } + let capsule: PaginationCapsule = res.json().await?; + + // handle pagination + // merge into "data", "pagination" is the key that hold details + + let page_items = match capsule.data { + serde_json::Value::Array(inner) => inner, + _ => { + return Err(anyhow!("Unexpected json value in data bundle")); + } + }; + if page_items.len() == 0 { + return Ok(data); + } + data.extend(page_items); + if capsule.pagination.page_index == capsule.pagination.total_pages { + return Ok(data); + } + continuation_token = Some(capsule.pagination.continuation_token); + } + } + + pub fn organization(&self, slug: &str) -> Organization { + Organization { client: self.clone(), slug: slug.to_string() } + } +} + +#[derive(Debug, Clone)] +#[fully_pub] +struct Organization { + client: AuthentifiedClient, + slug: String +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[fully_pub] +enum MembershipMode { + #[serde(rename = "Individuel")] + Individual, + #[serde(rename = "Couple")] + Couple, + #[serde(rename = "Individuel bienfaiteur")] + BenefactorIndividual, + #[serde(rename = "Couple bienfaiteur")] + BenefactorCouple, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[fully_pub] +struct OrderDetails { + id: u64, + // #[serde(with = "date_format")] + // date: DateTime + date: String +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[fully_pub] +struct FormAnswer { + amount: u64, + + #[serde(rename = "name")] + mode: MembershipMode, + + #[serde(rename = "payer")] + payer_user: PayerUserDetails, + + order: OrderDetails, + + #[serde(rename = "user")] + user: UserDetails, + + id: u64, + custom_fields: Vec +} + +impl Organization { + pub async fn get_details(&self) -> Result { + let details = self.client.simple_fetch(format!("organizations/{}", self.slug)).await?; + Ok(details) + } + + pub async fn get_form_answers(&self, form_slug: String) -> Result> { + let data = self.client.fetch_with_pagination( + format!("organizations/{}/forms/Membership/{}/participants?withDetails=true", self.slug, form_slug) + ).await?; + let mut answers: Vec = vec![]; + for entry in data { + answers.push(serde_json::from_value(entry).context("Cannot parse FormAnswer")?) + } + Ok(answers) + } +} + + diff --git a/src/main.rs b/src/main.rs index 0e302ce..78708fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,9 @@ -use anyhow::{Context, Result, anyhow}; -use url::Url; -use chrono::prelude::{NaiveDate, DateTime, Utc}; +mod paheko; +mod helloasso; + +use thiserror::Error; +use anyhow::{Context, Result}; +use chrono::prelude::{NaiveDate, DateTime, Utc, Datelike}; use strum::Display; use serde::{Serialize, Deserialize}; use std::collections::HashSet; @@ -34,20 +37,6 @@ struct Config { helloasso_password: String } -#[derive(Serialize, Debug)] -struct LoginPayload { - email: String, - password: String -} - -use thiserror::Error; - -#[derive(Error, Debug)] -enum APIClientError { - #[error("Received non-normal status code from API")] - InvalidStatusCode -} - static APP_USER_AGENT: &str = concat!( env!("CARGO_PKG_NAME"), "/", @@ -59,7 +48,7 @@ use std::fs; #[derive(Serialize, Deserialize, Debug)] struct UserCache { - helloasso_session: Option + helloasso_session: Option } #[derive(Display, Debug, Error)] @@ -100,294 +89,31 @@ fn load_user_cache() -> Result { Ok(cache) } +// todo: +// - make pagination working +// - create paheko client +// - get current paheko membership +// - function to convert participants to paheko members +// - clean up names and things +// - map custom fields with the right thing +// - handle linked users -#[derive(Clone, Serialize, Deserialize, Debug)] -struct HelloassoSession { - jwt: String -} - -enum LoginError { - TransportFailure(reqwest::Error) -} - -#[derive(Debug)] -struct HelloassoClient { - client: reqwest::Client, - base_url: Url, -} - -impl Default for HelloassoClient { - fn default() -> Self { - HelloassoClient { - client: HelloassoClient::get_base_client_builder() - .build() - .expect("reqwest client to be built"), - base_url: Url::parse("https://api.helloasso.com/v5/") - .expect("Valid helloasso API base URL") - } - } -} - -impl HelloassoClient { - - fn get_base_client_builder() -> reqwest::ClientBuilder { - let mut default_headers = reqwest::header::HeaderMap::new(); - default_headers.insert("Accept", "application/json".parse().unwrap()); - - let proxy = reqwest::Proxy::https("https://localhost:8999").unwrap(); - reqwest::Client::builder() - .proxy(proxy) - .default_headers(default_headers) - } - - - async fn login(&mut self, payload: LoginPayload) -> Result { - let mut login_commons_headers = reqwest::header::HeaderMap::new(); - login_commons_headers.insert( - "Origin", - "https://auth.helloasso.com".parse().expect("Header value to be OK") - ); - - let res = self.client.get(self.base_url.join("auth/antiforgerytoken")?) - .headers(login_commons_headers.clone()) - .send().await?; - let antiforgerytoken: String = res.json().await?; - - let res = self.client.post(self.base_url.join("auth/login")?) - .json(&payload) - .headers(login_commons_headers.clone()) - .header("x-csrf-token", antiforgerytoken) - .send() - .await?; - - if res.status() != 200 { - return Err(anyhow!("Unexpected status code from login")); - } - - fn get_jwt_from_cookies_headers(headers: &reqwest::header::HeaderMap) -> Option { - for (name_opt, value_raw) in headers { - let name = String::from(name_opt.as_str()); - if name.to_lowercase() != "set-cookie" { - continue - } - let value = String::from(value_raw.to_str().unwrap()); - if value.starts_with("tm5-HelloAsso") { - let jwt = value.split("tm5-HelloAsso=").nth(1)?.split(";").nth(0)?.trim().to_string(); - return Some(jwt); - } - } - None - } - - let jwt = get_jwt_from_cookies_headers(&res.headers()) - .context("Failed to find or parse JWT from login response")?; - - let session = HelloassoSession { jwt }; - - Ok(self.authentified_client(session)) - } - - fn authentified_client(&self, session: HelloassoSession) -> AuthentifiedHelloassoClient { - AuthentifiedHelloassoClient::new(self.base_url.clone(), session) - } -} - -#[derive(Debug, Clone)] -struct AuthentifiedHelloassoClient { - session: HelloassoSession, - client: reqwest::Client, - base_url: Url -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PaginationMeta { - continuation_token: String, - page_index: u64, - page_size: u64, - total_count: u64, - total_pages: u64 -} - -#[derive(Debug, Serialize, Deserialize)] -struct PaginationCapsule { - data: serde_json::Value, - pagination: PaginationMeta -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CustomFieldAnswer { - answer: String, - id: u64, - name: String - // missing type, it's probably always TextInput, if not, serde will fail to parse -} - - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UserDetails { - country: String, - email: String, - first_name: String, - last_name: String -} - - -// #[derive(Debug, Serialize, Deserialize)] -// #[serde(rename_all = "camelCase")] -// struct OrderDetails { -// date: -// form_ -// } - -impl AuthentifiedHelloassoClient { - /// each time we need to change the token, we will need to rebuild the client - fn new(base_url: Url, session: HelloassoSession) -> Self { - let mut auth_headers = reqwest::header::HeaderMap::new(); - auth_headers.insert("Authorization", format!("Bearer {}", session.jwt).parse().unwrap()); - - AuthentifiedHelloassoClient { - base_url, - session, - client: HelloassoClient::get_base_client_builder() - .default_headers(auth_headers) - .build() - .expect("reqwest client to be built") - } - } - - async fn verify_auth(&self) -> Result { - let res = self.client - .get(self.base_url.join("agg/user")?) - .send().await?; - return Ok(res.status() == 200); - } - - async fn get_user_details(&self) -> Result<()> { - let res = self.client - .get(self.base_url.join("agg/user")?) - .send().await?; - if res.status() != 200 { - return Err(APIClientError::InvalidStatusCode.into()); - } - let user_details: serde_json::Value = res.json().await?; - - dbg!(user_details); - - Ok(()) - } - - async fn fetch(&self, path: String) -> Result { - let res = self.client - .get(self.base_url.join(path.as_str())?) - .send().await?; - if res.status() != 200 { - return Err(APIClientError::InvalidStatusCode.into()); - } - let details: serde_json::Value = res.json().await?; - - // handle pagination - // merge into "data", "pagination" is the key that hold details - - Ok(details) - } - - async fn fetch_with_pagination(&self, path: String) -> Result> { - let mut data: Vec = vec![]; - let mut continuation_token: Option = None; - - loop { - let mut url = self.base_url.join(path.as_str())?; - if let Some(token) = &continuation_token { - url.query_pairs_mut().append_pair("continuationToken", token); - } - let res = self.client - .get(url) - .send().await?; - if res.status() != 200 { - return Err(APIClientError::InvalidStatusCode.into()); - } - let capsule: PaginationCapsule = res.json().await?; - - // handle pagination - // merge into "data", "pagination" is the key that hold details - - let page_items = match capsule.data { - serde_json::Value::Array(inner) => inner, - _ => { - return Err(anyhow!("Unexpected json value in data bundle")); - } - }; - if page_items.len() == 0 { - return Ok(data); - } - data.extend(page_items); - if capsule.pagination.page_index == capsule.pagination.total_pages { - return Ok(data); - } - continuation_token = Some(capsule.pagination.continuation_token); - } - } - - fn organization(&self, slug: &str) -> Organization { - Organization { client: self.clone(), slug: slug.to_string() } - } -} - -#[derive(Debug, Clone)] -struct Organization { - client: AuthentifiedHelloassoClient, - slug: String -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -enum MembershipMode { - #[serde(rename = "Individuel")] - Individual, - #[serde(rename = "Couple")] - Couple, - #[serde(rename = "Individuel bienfaiteur")] - BenefactorIndividual, - #[serde(rename = "Couple bienfaiteur")] - BenefactorCouple, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct FormAnswer { - amount: u64, - #[serde(rename = "name")] - mode: MembershipMode, - #[serde(rename = "payer")] - user: UserDetails, - id: u64, - custom_fields: Vec -} - -impl Organization { - async fn get_details(&self) -> Result { - let details = self.client.fetch(format!("organizations/{}", self.slug)).await?; - Ok(details) - } - - async fn get_form_answers(&self, form_slug: String) -> Result> { - let data = self.client.fetch_with_pagination( - format!("organizations/{}/forms/Membership/{}/participants?withDetails=true", self.slug, form_slug) - ).await?; - let mut answers: Vec = vec![]; - for entry in data { - answers.push(serde_json::from_value(entry).context("Cannot parse FormAnswer")?) - } - Ok(answers) - } +fn get_paheko_membership_from_ha_answers() { + } // TODO: find a better way to have the logic implemented -async fn get_auth_client_from_cache(user_cache: &mut UserCache, ha_client: &mut HelloassoClient, login_payload: LoginPayload) -> Result { +async fn get_auth_client_from_cache( + user_cache: &mut UserCache, + ha_client: &mut helloasso::Client, + login_payload: helloasso::LoginPayload +) -> Result { - async fn login(user_cache: &mut UserCache, ha_client: &mut HelloassoClient, login_payload: LoginPayload) -> Result { + async fn login( + user_cache: &mut UserCache, + ha_client: &mut helloasso::Client, + login_payload: helloasso::LoginPayload + ) -> Result { let auth_client = ha_client.login( login_payload ).await.context("Failed to login")?; @@ -415,17 +141,17 @@ async fn get_auth_client_from_cache(user_cache: &mut UserCache, ha_client: &mut }; } -// todo: -// - make pagination working -// - create paheko client -// - get current paheko membership -// - function to convert participants to paheko members -// - clean up names and things -// - map custom fields with the right thing -// - handle linked users -fn get_paheko_membership_from_ha_answers() { - +#[derive(Debug, Serialize, Deserialize, Clone)] +struct HelloassoReferences { + answer_id: u64, + order_id: u64 + // payment_id: u64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct ExternalReferences { + helloasso_ref: HelloassoReferences } /// for now we include the custom fields into the paheko user @@ -436,15 +162,16 @@ struct PahekoUser { id: Id, first_name: String, last_name: String, - email: String, + email: Option, phone: Option, address: String, city: String, postal_code: String, + country: String, skills: Option, job: Option, - birthday: Option // we will need to validate some data before + birthday: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -452,25 +179,15 @@ struct PahekoMembership { id: Id, users: Vec, campaign: String, - mode: MembershipMode, - inception_datum: DateTime -} - -struct CustomFieldsMapping { - helloasso_id: u64, - paheko_slug: String, - label: String - // address: u64, - // postal_code: u64, - // city: u64, - // phone: u64, - // skills: u64, - // birthday: u64, + mode: helloasso::MembershipMode, + inception_datum: DateTime, + external_references: ExternalReferences } /// rust how to access inner enum value #[derive(Debug, PartialEq, Clone, Copy)] -enum HelloAssoCustomFieldType { +enum HelloassoCustomFieldType { + Email, Address, PostalCode, City, @@ -481,28 +198,29 @@ enum HelloAssoCustomFieldType { LinkedUserFirstName } -impl TryFrom<&str> for HelloAssoCustomFieldType { +impl TryFrom<&str> for HelloassoCustomFieldType { type Error = (); fn try_from(subject: &str) -> Result { match subject { - "Prénom conjoint" => Ok(HelloAssoCustomFieldType::LinkedUserFirstName), - "ADRESSE" => Ok(HelloAssoCustomFieldType::Address), - "CODE POSTAL" => Ok(HelloAssoCustomFieldType::PostalCode), - "VILLE" => Ok(HelloAssoCustomFieldType::City), - "PROFESSION" => Ok(HelloAssoCustomFieldType::Job), - "TÉLÉPHONE" => Ok(HelloAssoCustomFieldType::Phone), - "DATE DE NAISSANCE" => Ok(HelloAssoCustomFieldType::Birthday), - "CENTRE D'INTÉRÊTS / COMPÉTENCES" => Ok(HelloAssoCustomFieldType::Skills), + "Prénom conjoint" => Ok(HelloassoCustomFieldType::LinkedUserFirstName), + "ADRESSE" => Ok(HelloassoCustomFieldType::Address), + "CODE POSTAL" => Ok(HelloassoCustomFieldType::PostalCode), + "VILLE" => Ok(HelloassoCustomFieldType::City), + "EMAIL" => Ok(HelloassoCustomFieldType::Email), + "PROFESSION" => Ok(HelloassoCustomFieldType::Job), + "TÉLÉPHONE" => Ok(HelloassoCustomFieldType::Phone), + "DATE DE NAISSANCE" => Ok(HelloassoCustomFieldType::Birthday), + "CENTRE D'INTÉRÊTS / COMPÉTENCES" => Ok(HelloassoCustomFieldType::Skills), _ => Err(()) } } } -fn read_custom_field(form_answer: &FormAnswer, custom_field: HelloAssoCustomFieldType) -> Option { +fn read_custom_field(form_answer: &helloasso::FormAnswer, custom_field: HelloassoCustomFieldType) -> Option { // FIXME: compute the type directly at deserialization with serde form_answer.custom_fields.iter() - .find(|f| HelloAssoCustomFieldType::try_from(f.name.as_str()) == Ok(custom_field)) + .find(|f| HelloassoCustomFieldType::try_from(f.name.as_str()) == Ok(custom_field)) .and_then(|cf| Some(cf.answer.clone())) } @@ -521,25 +239,33 @@ fn parse_normalize_phone(phone_number_opt: Option) -> Option { Some(parsed.to_string()) } + +/// remove year precision to comply with GDPR eu rules +fn parse_and_get_birthday_year(raw_date: String) -> Option { + let d_res = NaiveDate::parse_from_str(&raw_date.trim(), "%d/%m/%Y"); + let d = d_res.ok()?; + Some(d.year().try_into().ok()?) +} + async fn launch_adapter() -> Result<()> { dotenvy::dotenv()?; let config: Config = envy::from_env().context("Failed to load env vars")?; let mut user_cache = load_user_cache().context("Failed to load user cache")?; - let mut ha_client: HelloassoClient = Default::default(); + let mut ha_client: helloasso::Client = Default::default(); - let login_payload = LoginPayload { + let login_payload = helloasso::LoginPayload { email: config.helloasso_email, password: config.helloasso_password }; - let auth_client: AuthentifiedHelloassoClient = get_auth_client_from_cache(&mut user_cache, &mut ha_client, login_payload).await?; + let auth_client: helloasso::AuthentifiedClient = get_auth_client_from_cache(&mut user_cache, &mut ha_client, login_payload).await?; // dbg!(auth_client.get_user_details().await?); let slug = "l-etoile-de-bethleem-association-des-amis-de-la-chapelle-de-bethleem-d-aubevoye"; - let org: Organization = auth_client.organization(slug); + let org: helloasso::Organization = auth_client.organization(slug); // dbg!(org.get_details().await?); let answers = org.get_form_answers("2023-2024".to_string()).await?; @@ -556,9 +282,24 @@ async fn launch_adapter() -> Result<()> { let mut count: u64 = 0; let mut names: HashSet = HashSet::new(); + + // read_custom_field(&answer, HelloAssoCustomFieldType::Email).or(Some(answer.payer_user.email.clone())), + use email_address::*; + use std::str::FromStr; + fn choose_email(answer: &helloasso::FormAnswer) -> Option { + read_custom_field(&answer, HelloassoCustomFieldType::Email) + .and_then(|x| { + if !EmailAddress::is_valid(&x) { + None + } else { + Some(x) + } + }) + .or(Some(answer.payer_user.email.clone())) + } + for answer in answers { // TODO: parse birthday - // NaiveDate::parse_from_str dbg!(&answer); for custom_field in answer.custom_fields.iter() { names.insert(custom_field.name.clone()); @@ -568,32 +309,47 @@ async fn launch_adapter() -> Result<()> { id: generate_id(), first_name: answer.user.first_name.clone(), last_name: answer.user.last_name.clone(), - email: answer.user.email.clone(), - phone: parse_normalize_phone(read_custom_field(&answer, HelloAssoCustomFieldType::Phone)), - skills: read_custom_field(&answer, HelloAssoCustomFieldType::Skills), - address: read_custom_field(&answer, HelloAssoCustomFieldType::Address).expect("to have address"), - postal_code: read_custom_field(&answer, HelloAssoCustomFieldType::PostalCode).expect("to have postal code"), - city: read_custom_field(&answer, HelloAssoCustomFieldType::City).expect("to have city"), - job: read_custom_field(&answer, HelloAssoCustomFieldType::Job), - birthday: None + email: choose_email(&answer), + phone: parse_normalize_phone(read_custom_field(&answer, HelloassoCustomFieldType::Phone)), + skills: read_custom_field(&answer, HelloassoCustomFieldType::Skills), + address: read_custom_field(&answer, HelloassoCustomFieldType::Address).expect("to have address"), + postal_code: read_custom_field(&answer, HelloassoCustomFieldType::PostalCode).expect("to have postal code"), + city: read_custom_field(&answer, HelloassoCustomFieldType::City).expect("to have city"), + country: answer.payer_user.country.clone(), + job: read_custom_field(&answer, HelloassoCustomFieldType::Job), + birthday: read_custom_field(&answer, HelloassoCustomFieldType::Birthday).and_then(parse_and_get_birthday_year), + // FIXME: the reference will be in the data of the paheko activity, and will only + // reference the answer id }; let mut pk_membership = PahekoMembership { id: generate_id(), campaign: "".to_string(), inception_datum: Utc::now(), mode: answer.mode.clone(), - users: vec![paheko_user.id.clone()] + users: vec![paheko_user.id.clone()], + external_references: ExternalReferences { + helloasso_ref: HelloassoReferences { + answer_id: answer.id, + order_id: answer.order.id + } + } }; dbg!(&pk_membership.users); // then create optional linked user - if answer.mode == MembershipMode::Couple { + if answer.mode == helloasso::MembershipMode::Couple { let mut second_pk_user = paheko_user.clone(); second_pk_user.id = generate_id(); + second_pk_user.email = None; + second_pk_user.phone = None; + second_pk_user.skills = None; + second_pk_user.job = None; + second_pk_user.birthday = None; - match read_custom_field(&answer, HelloAssoCustomFieldType::LinkedUserFirstName) { + // add first_name + match read_custom_field(&answer, HelloassoCustomFieldType::LinkedUserFirstName) { Some(name) => { - second_pk_user.first_name = name + second_pk_user.first_name = name; }, None => { second_pk_user.first_name = "Conjoint".to_string(); @@ -615,15 +371,26 @@ async fn launch_adapter() -> Result<()> { // println!("{:?}", &pk_users.iter().map(|user| format!("{:?}", user.email)).collect::>()); - for u in pk_users { - println!("{} {}", u.email, u.phone.unwrap_or("".to_string())); + for u in pk_users.iter() { + println!("{:?}", (&u.first_name, &u.last_name, &u.email, &u.phone, &u.birthday, &u.country)); + } + for u in pk_users.iter() { + let email = u.email.clone(); + if email.is_none() { continue; } + println!("{:?},{:?}", email.unwrap(), format!("{} {}", &u.first_name, &u.last_name)); } - // then, request the current list of users // match with the email address // we consider the email address as the id for a helloasso user // then, upload the PahekoMembership + // in paheko, there is a custom field "external extensions data" which can be used to put an + // id, + // for each uses we extracted + // we check if there is an existing user by checking for the ha forn answer id + + + Ok(()) }