helloasso-paheko-adapter/src/main.rs

636 lines
20 KiB
Rust
Raw Normal View History

2023-11-03 17:44:11 +00:00
use anyhow::{Context, Result, anyhow};
2023-11-09 08:14:14 +00:00
use url::Url;
2023-11-03 17:44:11 +00:00
use chrono::prelude::{NaiveDate, DateTime, Utc};
2023-11-09 08:14:14 +00:00
use strum::Display;
use serde::{Serialize, Deserialize};
use std::collections::HashSet;
2023-11-09 08:14:14 +00:00
use rand::{thread_rng, Rng};
use phonenumber;
/// ID
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash)]
pub struct Id(pub u64);
impl Id {
pub fn to_string(&self) -> String {
format!("{:x}", self.0)
}
}
impl Into<String> for Id {
fn into(self) -> String {
format!("{:x}", self.0)
}
}
pub fn generate_id() -> Id {
Id(thread_rng().gen())
}
2023-11-03 17:44:11 +00:00
/// permanent config to store long-term config
/// used to ingest env settings
#[derive(Deserialize, Serialize, Debug)]
struct Config {
helloasso_email: String,
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"),
"/",
env!("CARGO_PKG_VERSION"),
);
// start user cache management
use std::fs;
#[derive(Serialize, Deserialize, Debug)]
struct UserCache {
helloasso_session: Option<HelloassoSession>
}
#[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)
}
#[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<AuthentifiedHelloassoClient> {
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<String> {
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<bool> {
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<serde_json::Value> {
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<Vec<serde_json::Value>> {
let mut data: Vec<serde_json::Value> = vec![];
let mut continuation_token: Option<String> = 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
}
2023-11-09 08:14:14 +00:00
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
2023-11-03 17:44:11 +00:00
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<CustomFieldAnswer>
}
impl Organization {
async fn get_details(&self) -> Result<serde_json::Value> {
let details = self.client.fetch(format!("organizations/{}", self.slug)).await?;
Ok(details)
}
async fn get_form_answers(&self, form_slug: String) -> Result<Vec<FormAnswer>> {
let data = self.client.fetch_with_pagination(
format!("organizations/{}/forms/Membership/{}/participants?withDetails=true", self.slug, form_slug)
).await?;
let mut answers: Vec<FormAnswer> = vec![];
for entry in data {
answers.push(serde_json::from_value(entry).context("Cannot parse FormAnswer")?)
}
Ok(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<AuthentifiedHelloassoClient> {
async fn login(user_cache: &mut UserCache, ha_client: &mut HelloassoClient, login_payload: LoginPayload) -> Result<AuthentifiedHelloassoClient> {
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?);
}
};
}
// 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() {
}
/// for now we include the custom fields into the paheko user
/// we don't have time to implement user settings to change the custom fields mapping
/// for now, manual mapping
2023-11-09 08:14:14 +00:00
#[derive(Debug, Serialize, Deserialize, Clone)]
2023-11-03 17:44:11 +00:00
struct PahekoUser {
2023-11-09 08:14:14 +00:00
id: Id,
2023-11-03 17:44:11 +00:00
first_name: String,
last_name: String,
email: String,
phone: Option<String>,
address: String,
city: String,
postal_code: String,
skills: Option<String>,
job: Option<String>,
birthday: Option<NaiveDate> // we will need to validate some data before
}
2023-11-09 08:14:14 +00:00
#[derive(Debug, Serialize, Deserialize, Clone)]
2023-11-03 17:44:11 +00:00
struct PahekoMembership {
2023-11-09 08:14:14 +00:00
id: Id,
users: Vec<Id>,
2023-11-03 17:44:11 +00:00
campaign: String,
2023-11-09 08:14:14 +00:00
mode: MembershipMode,
2023-11-03 17:44:11 +00:00
inception_datum: DateTime<Utc>
}
struct CustomFieldsMapping {
helloasso_id: u64,
paheko_slug: String,
label: String
// address: u64,
// postal_code: u64,
// city: u64,
// phone: u64,
// skills: u64,
// birthday: u64,
}
/// rust how to access inner enum value
#[derive(Debug, PartialEq, Clone, Copy)]
enum HelloAssoCustomFieldType {
2023-11-03 17:44:11 +00:00
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),
"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(())
2023-11-03 17:44:11 +00:00
}
}
}
fn read_custom_field(form_answer: &FormAnswer, custom_field: HelloAssoCustomFieldType) -> Option<String> {
// FIXME: compute the type directly at deserialization with serde
2023-11-03 17:44:11 +00:00
form_answer.custom_fields.iter()
.find(|f| HelloAssoCustomFieldType::try_from(f.name.as_str()) == Ok(custom_field))
2023-11-03 17:44:11 +00:00
.and_then(|cf| Some(cf.answer.clone()))
}
2023-11-09 08:14:14 +00:00
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())
}
2023-11-03 17:44:11 +00:00
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 login_payload = 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?;
// 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);
// 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());
2023-11-09 08:14:14 +00:00
// first, request the current list of membership in paheko that were created with helloasso
// get the list of payments associated
2023-11-03 17:44:11 +00:00
// first step: output a list of PahekoUser with PahekoMembership
let pk_memberships: Vec<PahekoMembership> = vec![];
let mut pk_users: Vec<PahekoUser> = vec![];
2023-11-09 08:14:14 +00:00
let mut pk_memberships: Vec<PahekoMembership> = vec![];
2023-11-03 17:44:11 +00:00
let mut count: u64 = 0;
let mut names: HashSet<String> = HashSet::new();
2023-11-03 17:44:11 +00:00
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());
count += 1;
}
2023-11-09 08:14:14 +00:00
let paheko_user = PahekoUser {
id: generate_id(),
2023-11-03 17:44:11 +00:00
first_name: answer.user.first_name.clone(),
last_name: answer.user.last_name.clone(),
email: answer.user.email.clone(),
2023-11-09 08:14:14 +00:00
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),
2023-11-03 17:44:11 +00:00
birthday: None
2023-11-09 08:14:14 +00:00
};
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()]
};
dbg!(&pk_membership.users);
// then create optional linked user
2023-11-09 08:14:14 +00:00
if answer.mode == MembershipMode::Couple {
let mut second_pk_user = paheko_user.clone();
second_pk_user.id = generate_id();
match read_custom_field(&answer, HelloAssoCustomFieldType::LinkedUserFirstName) {
Some(name) => {
second_pk_user.first_name = name
},
None => {
second_pk_user.first_name = "Conjoint".to_string();
eprintln!("Got a user with Couple mode but no additional name given!")
}
}
pk_membership.users.push(second_pk_user.id.clone());
pk_users.push(second_pk_user);
}
pk_users.push(paheko_user);
pk_memberships.push(pk_membership);
}
dbg!(&pk_users);
dbg!(&pk_memberships);
dbg!(&pk_users.len());
dbg!(&pk_memberships.len());
// println!("{:?}", &pk_users.iter().map(|user| format!("{:?}", user.email)).collect::<Vec<String>>());
for u in pk_users {
println!("{} {}", u.email, u.phone.unwrap_or("".to_string()));
2023-11-03 17:44:11 +00:00
}
2023-11-09 08:14:14 +00:00
2023-11-03 17:44:11 +00:00
// then, request the current list of users
2023-11-09 08:14:14 +00:00
// match with the email address
// we consider the email address as the id for a helloasso user
2023-11-03 17:44:11 +00:00
// then, upload the PahekoMembership
Ok(())
}
#[tokio::main]
async fn main() {
let res = launch_adapter().await;
dbg!(res);
}