refactor: split Helloasso logic into separate file
This commit is contained in:
parent
bbdbbbfb8a
commit
a604d63622
4 changed files with 482 additions and 359 deletions
21
Cargo.lock
generated
21
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
333
src/helloasso.rs
Normal file
333
src/helloasso.rs
Normal file
|
@ -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<AuthentifiedClient> {
|
||||
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 = 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<bool> {
|
||||
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<serde_json::Value> {
|
||||
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<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?;
|
||||
|
||||
Ok(details)
|
||||
}
|
||||
|
||||
pub 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);
|
||||
}
|
||||
}
|
||||
|
||||
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<Utc>
|
||||
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<CustomFieldAnswer>
|
||||
}
|
||||
|
||||
impl Organization {
|
||||
pub async fn get_details(&self) -> Result<serde_json::Value> {
|
||||
let details = self.client.simple_fetch(format!("organizations/{}", self.slug)).await?;
|
||||
Ok(details)
|
||||
}
|
||||
|
||||
pub 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
481
src/main.rs
481
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<HelloassoSession>
|
||||
helloasso_session: Option<helloasso::WebSession>
|
||||
}
|
||||
|
||||
#[derive(Display, Debug, Error)]
|
||||
|
@ -100,294 +89,31 @@ fn load_user_cache() -> Result<UserCache, LoadError> {
|
|||
|
||||
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
|
||||
}
|
||||
fn get_paheko_membership_from_ha_answers() {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[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<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 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 HelloassoClient, login_payload: LoginPayload) -> Result<AuthentifiedHelloassoClient> {
|
||||
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")?;
|
||||
|
@ -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<String>,
|
||||
|
||||
phone: Option<String>,
|
||||
address: String,
|
||||
city: String,
|
||||
postal_code: String,
|
||||
country: String,
|
||||
skills: Option<String>,
|
||||
job: Option<String>,
|
||||
birthday: Option<NaiveDate> // we will need to validate some data before
|
||||
birthday: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
|
@ -452,25 +179,15 @@ struct PahekoMembership {
|
|||
id: Id,
|
||||
users: Vec<Id>,
|
||||
campaign: String,
|
||||
mode: MembershipMode,
|
||||
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,
|
||||
mode: helloasso::MembershipMode,
|
||||
inception_datum: DateTime<Utc>,
|
||||
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<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),
|
||||
"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<String> {
|
||||
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))
|
||||
.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<String>) -> Option<String> {
|
|||
Some(parsed.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()?)
|
||||
}
|
||||
|
||||
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<String> = 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<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()))
|
||||
}
|
||||
|
||||
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::<Vec<String>>());
|
||||
|
||||
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(())
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue