mod utils; 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; use phonenumber; use utils::generate_id; use paheko::UserSummary; /// permanent config to store long-term config /// used to ingest env settings #[derive(Deserialize, Serialize, Debug)] struct Config { helloasso_email: String, helloasso_password: String, paheko_base_url: String, paheko_client_id: String, paheko_client_secret: String, } static APP_USER_AGENT: &str = concat!( env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"), ); // start user cache management use std::fs; #[derive(Serialize, Deserialize, Debug)] struct UserCache { helloasso_session: Option<helloasso::WebSession> } #[derive(Display, Debug, Error)] #[strum(serialize_all = "snake_case")] enum LoadError { XDG, Fs, FailedToParse, FailedToEncode, FailedToCreate, FailedToWrite } fn write_user_cache(cache: &UserCache) -> Result<(), LoadError> { let xdg_dirs = xdg::BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")) .map_err(|e| { LoadError::XDG })?; let user_cache_path = xdg_dirs.place_cache_file("session.json").map_err(|e| { LoadError::FailedToCreate })?; let encoded_cache = serde_json::to_string(&cache).map_err(|e| { LoadError::FailedToEncode })?; fs::write(&user_cache_path, encoded_cache.as_str()).map_err(|e| { LoadError::FailedToWrite })?; Ok(()) } fn load_user_cache() -> Result<UserCache, LoadError> { let xdg_dirs = xdg::BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")) .map_err(|e| { LoadError::XDG })?; let user_cache_path = xdg_dirs.get_cache_file("session.json"); if !user_cache_path.exists() { let default_cache = UserCache { helloasso_session: None }; write_user_cache(&default_cache)?; } let session_content = fs::read_to_string(user_cache_path).map_err(|e| { LoadError::Fs })?; let cache: UserCache = serde_json::from_str(&session_content).map_err(|e| { LoadError::FailedToParse })?; 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 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 helloasso::Client, login_payload: helloasso::LoginPayload ) -> Result<helloasso::AuthentifiedClient> { async fn login( user_cache: &mut UserCache, ha_client: &mut helloasso::Client, login_payload: helloasso::LoginPayload ) -> Result<helloasso::AuthentifiedClient> { let auth_client = ha_client.login( login_payload ).await.context("Failed to login")?; user_cache.helloasso_session = Some(auth_client.session.clone()); write_user_cache(&user_cache).expect("unable to write user cache"); println!("Logged in and wrote token to cache"); Ok(auth_client) } match &user_cache.helloasso_session { Some(cached_session) => { let auth_client = ha_client.authentified_client(cached_session.clone()); if !auth_client.verify_auth().await? { println!("Need to relog, token invalid"); return Ok(login(user_cache, ha_client, login_payload).await?) } println!("Used anterior token"); return Ok(auth_client); }, None => { println!("First time login"); return Ok(login(user_cache, ha_client, login_payload).await?); } }; } /// rust how to access inner enum value #[derive(Debug, PartialEq, Clone, Copy)] enum HelloassoCustomFieldType { Email, Address, PostalCode, City, Phone, Job, Skills, Birthday, LinkedUserFirstName } impl TryFrom<&str> for HelloassoCustomFieldType { type Error = (); fn try_from(subject: &str) -> Result<Self, Self::Error> { match subject { "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: &helloasso::FormAnswer, custom_field: HelloassoCustomFieldType) -> Option<String> { // 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)) .and_then(|cf| Some(cf.answer.clone())) } fn parse_normalize_phone(phone_number_opt: Option<String>) -> Option<String> { let number_raw = phone_number_opt?; let parsed = match phonenumber::parse(Some(phonenumber::country::Id::FR), number_raw) { Ok(r) => { r }, Err(_e) => { return None; } }; Some(parsed.to_string()) } fn normalize_str(subject: String) -> String { subject.trim().replace("\n", ";").to_string() } /// remove year precision to comply with GDPR eu rules fn parse_and_get_birthday_year(raw_date: String) -> Option<u32> { let d_res = NaiveDate::parse_from_str(&raw_date.trim(), "%d/%m/%Y"); let d = d_res.ok()?; Some(d.year().try_into().ok()?) } fn helloasso_to_paheko_membership(helloasso_membership: &helloasso::MembershipMode) -> paheko::MembershipMode { match helloasso_membership { helloasso::MembershipMode::Couple => paheko::MembershipMode::Couple, helloasso::MembershipMode::Individual => paheko::MembershipMode::Individual, helloasso::MembershipMode::BenefactorCouple => paheko::MembershipMode::BenefactorCouple, helloasso::MembershipMode::BenefactorIndividual => paheko::MembershipMode::BenefactorIndividual } } 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 paheko_client: paheko::Client = paheko::Client::new("http://localhost:8082/api/".to_string()); let paheko_credentials = paheko::Credentials { client_id: config.paheko_client_id, client_secret: config.paheko_client_secret }; let paheko_client: paheko::AuthentifiedClient = paheko_client.login(paheko_credentials).await?; let mut ha_client: helloasso::Client = Default::default(); let login_payload = helloasso::LoginPayload { email: config.helloasso_email, password: config.helloasso_password }; 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: helloasso::Organization = auth_client.organization(slug); // dbg!(org.get_details().await?); let answers = org.get_form_answers("2023-2024".to_string()).await?; // dbg!(&answers); println!("Got {} answers to the membership form. Processing...", &answers.len()); // first, request the current list of membership in paheko that were created with helloasso // get the list of payments associated // first step: output a list of PahekoUser with PahekoMembership let pk_memberships: Vec<paheko::Membership> = vec![]; let mut pk_users: Vec<paheko::User> = vec![]; let mut pk_memberships: Vec<paheko::Membership> = vec![]; // read_custom_field(&answer, HelloAssoCustomFieldType::Email).or(Some(answer.payer_user.email.clone())), use email_address::*; fn choose_email(answer: &helloasso::FormAnswer) -> Option<String> { 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())) } // get summary of users let mut existing_users = paheko_client.get_users().await.context("Get users")?; // get summary of transactions for that year let existing_transactions = paheko_client.get_transactions(1).await.context("Get transactions")?; // TODO: before creating any users, get the current maximum id of the users table to predict // the next auto-incrementing id. let mut pk_next_id = paheko_client.get_user_next_id().await.context("Get paheko users next id")?; for answer in answers { eprintln!("Processing answer:"); let email = choose_email(&answer); eprintln!(" email: {:?}", email); let mut pk_user = paheko::User { id: utils::Id(0), first_name: Some(normalize_str(answer.user.first_name.clone())), last_name: normalize_str(answer.user.last_name.clone()), email, phone: parse_normalize_phone(read_custom_field(&answer, HelloassoCustomFieldType::Phone)), skills: read_custom_field(&answer, HelloassoCustomFieldType::Skills).map(normalize_str), address: read_custom_field(&answer, HelloassoCustomFieldType::Address) .map(normalize_str) .expect("to have address"), postal_code: read_custom_field(&answer, HelloassoCustomFieldType::PostalCode).expect("to have postal code"), city: read_custom_field(&answer, HelloassoCustomFieldType::City) .map(normalize_str) .expect("to have city"), country: answer.payer_user.country.clone().trim()[..=1].to_string(), // ISO 3166-1 alpha-2 job: read_custom_field(&answer, HelloassoCustomFieldType::Job).map(normalize_str), birth_year: read_custom_field(&answer, HelloassoCustomFieldType::Birthday).and_then(parse_and_get_birthday_year), register_time: answer.order.inception_time, }; // apply custom user override // this particular answer had duplicate phone and email from another answer if answer.id == 64756582 { pk_user.email = None; pk_user.phone = None; } // check for existing transactions if let Some(_) = existing_transactions.iter().find( |summary| summary.reference == format!("HA/{}", answer.id) ) { eprintln!(" Skipped: existing transaction found"); continue; } let existing_user_opt = existing_users.iter().find(|user| user.email == pk_user.email).cloned(); // check for existing paheko user, or create paheko user let pk_user_summary = match existing_user_opt.clone() { Some(user) => user, None => { let c = paheko_client.create_user( &pk_user, pk_next_id.clone() ).await.context("Expected to create paheko user")?; eprintln!(" Created paheko user"); pk_next_id += 1; existing_users.push(c.clone()); c } }; let mut pk_membership = paheko::Membership { id: generate_id(), campaign: "".to_string(), inception_time: Utc::now(), mode: helloasso_to_paheko_membership(&answer.mode), users: vec![pk_user.id.clone()], external_references: paheko::ExternalReferences { helloasso_ref: paheko::HelloassoReferences { answer_id: answer.id, order_id: answer.order.id } } }; // then create optional linked user if answer.mode == helloasso::MembershipMode::Couple { let mut second_pk_user = pk_user.clone(); second_pk_user.id = utils::Id(0); second_pk_user.email = None; second_pk_user.phone = None; second_pk_user.skills = None; second_pk_user.job = None; second_pk_user.birth_year = None; // add first_name match read_custom_field(&answer, HelloassoCustomFieldType::LinkedUserFirstName) { Some(name) => { second_pk_user.first_name = Some(name); }, None => { second_pk_user.first_name = None; eprintln!("Warn: Got a user with Couple mode but no additional name given!") } } if existing_user_opt.is_none() { let second_pk_user_summary = paheko_client.create_user(&second_pk_user, pk_next_id) .await.context("Expected to create second paheko user")?; eprintln!(" Created conjoint paheko user"); pk_next_id += 1; } // TODO: get existing linked user from previous year pk_membership.users.push(second_pk_user.id.clone()); pk_users.push(second_pk_user); } // add activity paheko_client.register_user_to_service(&pk_user_summary).await.context("Registering user to paheko server")?; pk_users.push(pk_user); pk_memberships.push(pk_membership); } dbg!(&pk_users); dbg!(&pk_memberships); dbg!(&pk_users.len()); dbg!(&pk_memberships.len()); Ok(()) } #[tokio::main] async fn main() { let res = launch_adapter().await; dbg!(res); }