use anyhow::{Context, Result, anyhow}; use url::Url; use serde::{Serialize, Deserialize}; use fully_pub::fully_pub; use crate::utils::Id; use chrono::prelude::{NaiveDate, DateTime, Utc, Datelike}; use thiserror::Error; #[derive(Debug, Serialize, Deserialize, Clone)] #[fully_pub] struct HelloassoReferences { answer_id: u64, order_id: u64 // payment_id: u64, } #[derive(Debug, Serialize, Deserialize, Clone)] #[fully_pub] struct ExternalReferences { helloasso_refs: HelloassoReferences } /// 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 #[derive(Debug, Serialize, Deserialize, Clone)] #[fully_pub] struct User { id: Id, first_name: Option<String>, last_name: String, email: Option<String>, phone: Option<String>, address: String, city: String, postal_code: String, country: String, skills: Option<String>, job: Option<String>, birth_year: Option<u32>, register_time: DateTime<Utc> } #[derive(Debug, Serialize, Deserialize, Clone)] #[fully_pub] struct UserSummary { id: Id, first_name: Option<String>, last_name: String, email: Option<String> } #[derive(Debug, Serialize, Deserialize, Clone)] #[fully_pub] struct Membership { id: Id, users: Vec<Id>, campaign_name: String, mode_name: String, start_time: DateTime<Utc>, end_time: DateTime<Utc>, payed_amount: f64, external_references: ExternalReferences, } #[derive(Debug, Clone)] #[fully_pub] enum TransactionKind { Expense, Revenue } impl Into<String> for TransactionKind { fn into(self) -> String { match self { TransactionKind::Expense => "EXPENSE".to_string(), TransactionKind::Revenue => "REVENUE".to_string() } } } #[derive(Debug, Clone)] #[fully_pub] struct SimpleTransaction { label: String, kind: TransactionKind, inception_time: DateTime<Utc>, amount: f64, credit_account_code: String, debit_account_code: String, reference: String, linked_users: Vec<Id>, linked_services: Vec<Id> } #[derive(Debug)] #[fully_pub] struct Client { client: reqwest::Client, base_url: Url, } #[derive(Error, Debug)] enum APIClientError { #[error("Received non-normal status code from API")] InvalidStatusCode } #[derive(Debug, Clone)] #[fully_pub] struct Credentials { client_id: String, client_secret: String } impl Default for Client { fn default() -> Self { Client { client: Client::get_base_client_builder() .build() .expect("Expected reqwest client to be built"), base_url: Url::parse("https://paheko.etoiledebethleem.fr/api/") // the traling slash is important .expect("Expected valid paheko API base URL") } } } use base64_light::base64_encode; fn build_auth_headers(credentials: &Credentials) -> reqwest::header::HeaderMap { let mut login_headers = reqwest::header::HeaderMap::new(); login_headers.insert( "Authorization", format!("Basic {}", &base64_encode( &format!("{}:{}", &credentials.client_id, &credentials.client_secret) )).parse().expect("Header value to be OK") ); login_headers } impl Client { pub fn new(base_url: String) -> Client { Client { client: Client::get_base_client_builder() .build() .expect("Expected reqwest client to be built"), base_url: Url::parse(&base_url) // the traling slash is important .expect("Expected valid paheko API base URL") } } 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::http("http://localhost:8998").unwrap(); reqwest::Client::builder() .proxy(proxy) .default_headers(default_headers) } pub async fn login(&mut self, credentials: Credentials) -> Result<AuthentifiedClient> { let hypothetic_client = self.authentified_client(credentials); let query: String = r#" SELECT key,value FROM config WHERE key="org_name" "#.to_string(); match hypothetic_client.sql_query(query).await { Ok(_value) => { Ok(hypothetic_client) }, Err(err) => { Err(anyhow!("Failed to authenticate: Credentials provided are invalids, {:?}", err)) } } } pub fn authentified_client(&self, credentials: Credentials) -> AuthentifiedClient { AuthentifiedClient::new(self.base_url.clone(), credentials) } } #[derive(Debug, Clone)] pub struct AuthentifiedClient { credentials: Credentials, client: reqwest::Client, base_url: Url } // SELECT id,nom AS first_name,last_name,email,external_custom_data FROM users LIMIT 5; #[derive(Debug, Deserialize)] #[fully_pub] struct SimpleUser { id: u32, first_name: String, last_name: String, email: Option<String>, external_custom_data: Option<String> } #[derive(Debug, Deserialize)] #[fully_pub] struct SqlQueryOutput { count: u64, results: serde_json::Value } #[derive(Debug, Deserialize)] #[fully_pub] struct TransactionSummary { id: u64, reference: String } #[derive(Debug)] #[fully_pub] struct UserServiceRegistration { id: Id } impl AuthentifiedClient { pub fn new(base_url: Url, credentials: Credentials) -> Self { AuthentifiedClient { client: Client::get_base_client_builder() .default_headers(build_auth_headers(&credentials)) .build() .expect("Expect client to be built"), credentials, base_url } } pub async fn sql_query(&self, query: String) -> Result<SqlQueryOutput> { #[derive(Serialize)] struct Payload { sql: String } let payload = Payload { sql: query }; let path = self.base_url.join("sql")?; let res = self.client .post(path) .json(&payload) .send().await?; if res.status() != 200 { dbg!(res); return Err(APIClientError::InvalidStatusCode.into()); } Ok(res.json().await.context("Sql query")?) } pub async fn get_users(&self) -> Result<Vec<UserSummary>> { let query: String = r#" SELECT id,nom AS first_name,last_name,email FROM users; "#.to_string(); let users_val = self.sql_query(query).await.context("Fetching users")?; Ok(serde_json::from_value(users_val.results)?) } pub async fn get_next_id(&self, table_name: &str) -> Result<u64> { let query: String = format!(r#" SELECT id FROM {} ORDER BY id DESC LIMIT 1 "#, table_name).to_string(); let data = self.sql_query(query).await.context("Fetching next id from table")?; #[derive(Deserialize)] struct Entry { id: u64 } let ids: Vec<Entry> = serde_json::from_value(data.results)?; Ok(ids.iter().nth(0).map(|x| x.id).unwrap_or(1)+1) } pub async fn get_transactions(&self, id_year: u32) -> Result<Vec<TransactionSummary>> { let query: String = format!(r#" SELECT id,reference FROM acc_transactions WHERE id_year={} AND reference LIKE 'HA/%'; "#, id_year).to_string(); let val = self.sql_query(query).await.context("Fetching transactions")?; Ok(serde_json::from_value(val.results)?) } pub async fn create_user(&self, user: &User, next_id: u64) -> Result<UserSummary> { // single-user import // create virtual file let u = user.clone(); let mut csv_content: String = String::new(); csv_content.push_str("numero,nom,last_name,adresse,code_postal,ville,pays,telephone,email,annee_naissance,profession,interets,lettre_infos,date_inscription\n"); csv_content.push_str( format!("{},{:?},{:?},{:?},{:?},{:?},{:?},{:?},{:?},{},{:?},{:?},{},{}\n", "".to_string(), u.first_name.clone().unwrap_or("".to_string()), u.last_name.clone(), u.address, u.postal_code, u.city, u.country, u.phone.unwrap_or("".to_string()), u.email.clone().unwrap_or("".to_string()), u.birth_year.map(|x| format!("{}", x)).unwrap_or("".to_string()), u.job.unwrap_or("".to_string()), u.skills.unwrap_or("".to_string()), 1, user.register_time.format("%d/%m/%Y") ).as_str()); use reqwest::multipart::Form; use reqwest::multipart::Part; let part = Part::text(csv_content).file_name("file"); let form = Form::new() .part("file", part); let res = self.client .post(self.base_url.join("user/import/")?) .multipart(form) .send().await?; if res.status() != 200 { return Err(APIClientError::InvalidStatusCode.into()); } Ok( UserSummary { id: Id(next_id), first_name: u.first_name, last_name: u.last_name, email: u.email } ) } pub async fn register_user_to_service(&self, user: &UserSummary, user_membership: &Membership, next_id: u64) -> Result<UserServiceRegistration> { // single-user import // create virtual file let u = user.clone(); let mut csv_content: String = String::new(); csv_content.push_str( r#""Numéro de membre","Activité","Tarif","Date d'inscription","Date d'expiration","Montant à régler","Payé ?""#); csv_content.push_str("\n"); csv_content.push_str( format!("{},{:?},{:?},{:?},{:?},{:?},{:?}\n", u.id, user_membership.campaign_name, user_membership.mode_name, user_membership.start_time.format("%d/%m/%Y").to_string(), user_membership.end_time.format("%d/%m/%Y").to_string(), format!("{}", user_membership.payed_amount), "Oui" ).as_str()); use reqwest::multipart::Form; use reqwest::multipart::Part; let part = Part::text(csv_content).file_name("file"); let form = Form::new() .part("file", part); let res = self.client .post(self.base_url.join("services/subscriptions/import")?) .multipart(form) .send().await?; if res.status() != 200 { return Err(APIClientError::InvalidStatusCode.into()); } Ok(UserServiceRegistration { id: Id(next_id) }) } pub async fn register_transaction(&self, transaction: SimpleTransaction) -> Result<()> { use reqwest::multipart::Form; let mut form = Form::new() .text("id_year", "1") .text("label", transaction.label) .text("date", transaction.inception_time.format("%d/%m/%Y").to_string()) .text("type", Into::<String>::into(transaction.kind)) .text("amount", format!("{}", transaction.amount)) .text("debit", transaction.debit_account_code) .text("credit", transaction.credit_account_code) .text("reference", transaction.reference) ; for linked_id in transaction.linked_users { form = form.text("linked_users[]", format!("{}", linked_id.0)); } for linked_id in transaction.linked_services { form = form.text("linked_services[]", format!("{}", linked_id.0)); } let res = self.client .post(self.base_url.join("accounting/transaction")?) .multipart(form) .send().await?; if res.status() != 200 { return Err(APIClientError::InvalidStatusCode.into()); } Ok(()) } }