From 11052951b74b9ad4b6a9412ae490086235f9154b Mon Sep 17 00:00:00 2001 From: Joris Date: Sun, 3 Jan 2021 13:40:40 +0100 Subject: Rewrite in Rust --- src/assets.rs | 27 +++ src/controller/balance.rs | 71 ++++++ src/controller/categories.rs | 141 ++++++++++++ src/controller/error.rs | 31 +++ src/controller/incomes.rs | 221 ++++++++++++++++++ src/controller/login.rs | 86 +++++++ src/controller/mod.rs | 9 + src/controller/payments.rs | 227 +++++++++++++++++++ src/controller/statistics.rs | 30 +++ src/controller/utils.rs | 119 ++++++++++ src/controller/wallet.rs | 13 ++ src/db/categories.rs | 132 +++++++++++ src/db/incomes.rs | 494 ++++++++++++++++++++++++++++++++++++++++ src/db/jobs.rs | 56 +++++ src/db/mod.rs | 6 + src/db/payments.rs | 525 +++++++++++++++++++++++++++++++++++++++++++ src/db/users.rs | 144 ++++++++++++ src/db/utils.rs | 3 + src/jobs/jobs.rs | 28 +++ src/jobs/mod.rs | 2 + src/jobs/weekly_report.rs | 55 +++++ src/mail.rs | 59 +++++ src/main.rs | 88 ++++++++ src/model/action.rs | 1 + src/model/category.rs | 20 ++ src/model/config.rs | 8 + src/model/frequency.rs | 31 +++ src/model/income.rs | 40 ++++ src/model/job.rs | 5 + src/model/login.rs | 5 + src/model/mod.rs | 9 + src/model/payment.rs | 53 +++++ src/model/report.rs | 16 ++ src/model/user.rs | 8 + src/payer.rs | 38 ++++ src/queries.rs | 62 +++++ src/routes.rs | 219 ++++++++++++++++++ src/templates.rs | 97 ++++++++ src/utils/mod.rs | 1 + src/utils/text.rs | 19 ++ src/validation/category.rs | 18 ++ src/validation/income.rs | 22 ++ src/validation/login.rs | 11 + src/validation/mod.rs | 5 + src/validation/payment.rs | 25 +++ src/validation/utils.rs | 54 +++++ 46 files changed, 3334 insertions(+) create mode 100644 src/assets.rs create mode 100644 src/controller/balance.rs create mode 100644 src/controller/categories.rs create mode 100644 src/controller/error.rs create mode 100644 src/controller/incomes.rs create mode 100644 src/controller/login.rs create mode 100644 src/controller/mod.rs create mode 100644 src/controller/payments.rs create mode 100644 src/controller/statistics.rs create mode 100644 src/controller/utils.rs create mode 100644 src/controller/wallet.rs create mode 100644 src/db/categories.rs create mode 100644 src/db/incomes.rs create mode 100644 src/db/jobs.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/payments.rs create mode 100644 src/db/users.rs create mode 100644 src/db/utils.rs create mode 100644 src/jobs/jobs.rs create mode 100644 src/jobs/mod.rs create mode 100644 src/jobs/weekly_report.rs create mode 100644 src/mail.rs create mode 100644 src/main.rs create mode 100644 src/model/action.rs create mode 100644 src/model/category.rs create mode 100644 src/model/config.rs create mode 100644 src/model/frequency.rs create mode 100644 src/model/income.rs create mode 100644 src/model/job.rs create mode 100644 src/model/login.rs create mode 100644 src/model/mod.rs create mode 100644 src/model/payment.rs create mode 100644 src/model/report.rs create mode 100644 src/model/user.rs create mode 100644 src/payer.rs create mode 100644 src/queries.rs create mode 100644 src/routes.rs create mode 100644 src/templates.rs create mode 100644 src/utils/mod.rs create mode 100644 src/utils/text.rs create mode 100644 src/validation/category.rs create mode 100644 src/validation/income.rs create mode 100644 src/validation/login.rs create mode 100644 src/validation/mod.rs create mode 100644 src/validation/payment.rs create mode 100644 src/validation/utils.rs (limited to 'src') diff --git a/src/assets.rs b/src/assets.rs new file mode 100644 index 0000000..dc46c78 --- /dev/null +++ b/src/assets.rs @@ -0,0 +1,27 @@ +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs; +use std::iter::FromIterator; + +pub fn get() -> HashMap { + let paths = fs::read_dir("assets").unwrap().map(|e| { + let path = format!("{}", e.unwrap().path().display()); + let file = fs::read(&path).unwrap(); + let mut path_iter = path.split("/"); + path_iter.next(); + let name = path_iter.collect::>().join("/"); + let hashed = format!("/assets/{}/{}", sha256(file), name); + (name, hashed) + }); + HashMap::from_iter(paths) +} + +fn sha256(input: Vec) -> String { + let mut hasher = Sha256::new(); + hasher.update(input); + hasher + .finalize() + .iter() + .map(|b| format!("{:x}", b)) + .collect() +} diff --git a/src/controller/balance.rs b/src/controller/balance.rs new file mode 100644 index 0000000..228ff04 --- /dev/null +++ b/src/controller/balance.rs @@ -0,0 +1,71 @@ +use hyper::{Body, Response}; +use std::collections::HashMap; +use tera::Context; + +use crate::controller::utils; +use crate::controller::wallet::Wallet; +use crate::db; +use crate::model::user::User; +use crate::payer; +use crate::templates; + +pub async fn get(wallet: &Wallet) -> Response { + let users = db::users::list(&wallet.pool).await; + + let incomes_from = db::incomes::defined_for_all(&wallet.pool).await; + let user_incomes = match incomes_from { + Some(from) => db::incomes::cumulative(&wallet.pool, from).await, + None => HashMap::new(), + }; + let template_user_incomes = + get_template_user_incomes(&users, &user_incomes); + let total_income: i64 = user_incomes.values().sum(); + + let user_payments = db::payments::repartition(&wallet.pool).await; + let template_user_payments = + get_template_user_payments(&users, &user_payments); + let total_payments: i64 = + user_payments.clone().into_iter().map(|p| p.1).sum(); + + let exceeding_payers = + payer::exceeding(&users, &user_incomes, &user_payments); + + let mut context = Context::new(); + context.insert("header", &templates::Header::Balance); + context.insert("connected_user", &wallet.user); + context.insert( + "incomes_from", + &incomes_from.map(|d| d.format("%d/%m/%Y").to_string()), + ); + context.insert("total_income", &total_income); + context.insert("user_incomes", &template_user_incomes); + context.insert("total_payments", &total_payments); + context.insert("user_payments", &template_user_payments); + context.insert("exceeding_payers", &exceeding_payers); + + utils::template(&wallet.assets, &wallet.templates, "balance.html", context) +} + +fn get_template_user_payments( + users: &Vec, + user_payments: &HashMap, +) -> Vec<(String, i64)> { + let mut user_payments: Vec<(String, i64)> = users + .into_iter() + .map(|u| (u.name.clone(), *user_payments.get(&u.id).unwrap_or(&0))) + .collect(); + user_payments.sort_by_key(|i| i.1); + user_payments +} + +fn get_template_user_incomes( + users: &Vec, + user_incomes: &HashMap, +) -> Vec<(String, i64)> { + let mut user_incomes: Vec<(String, i64)> = users + .into_iter() + .map(|u| (u.name.clone(), *user_incomes.get(&u.id).unwrap_or(&0))) + .collect(); + user_incomes.sort_by_key(|i| i.1); + user_incomes +} diff --git a/src/controller/categories.rs b/src/controller/categories.rs new file mode 100644 index 0000000..b1a3664 --- /dev/null +++ b/src/controller/categories.rs @@ -0,0 +1,141 @@ +use hyper::{Body, Response}; +use std::collections::HashMap; +use tera::Context; + +use crate::controller::utils; +use crate::controller::wallet::Wallet; +use crate::db; +use crate::queries; +use crate::templates; +use crate::validation; + +pub async fn table( + wallet: &Wallet, + query: queries::Categories, +) -> Response { + let categories = db::categories::list(&wallet.pool).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Categories); + context.insert("connected_user", &wallet.user); + context.insert("categories", &categories); + context.insert("highlight", &query.highlight); + + utils::template( + &wallet.assets, + &wallet.templates, + "category/table.html", + context, + ) +} + +pub async fn create_form(wallet: &Wallet) -> Response { + create_form_feedback(wallet, HashMap::new(), None).await +} + +async fn create_form_feedback( + wallet: &Wallet, + form: HashMap, + error: Option, +) -> Response { + let mut context = Context::new(); + context.insert("header", &templates::Header::Categories); + context.insert("connected_user", &wallet.user.clone()); + context.insert("form", &form); + context.insert("error", &error); + + utils::template( + &wallet.assets, + &wallet.templates, + "category/create.html", + context, + ) +} + +pub async fn create( + wallet: &Wallet, + form: HashMap, +) -> Response { + let error = |e: &str| { + create_form_feedback(wallet, form.clone(), Some(e.to_string())) + }; + + match validation::category::create(&form) { + Some(category) => { + match db::categories::create(&wallet.pool, &category).await { + Some(id) => { + utils::redirect(&format!("/categories?highlight={}", id)) + } + None => error("Erreur serveur").await, + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn update_form(id: i64, wallet: &Wallet) -> Response { + update_form_feedback(id, wallet, HashMap::new(), None).await +} + +async fn update_form_feedback( + id: i64, + wallet: &Wallet, + form: HashMap, + error: Option, +) -> Response { + let category = db::categories::get(&wallet.pool, id).await; + let is_category_used = + db::payments::is_category_used(&wallet.pool, id).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Categories); + context.insert("connected_user", &wallet.user); + context.insert("id", &id); + context.insert("category", &category); + context.insert("is_category_used", &is_category_used); + context.insert("form", &form); + context.insert("error", &error); + + utils::template( + &wallet.assets, + &wallet.templates, + "category/update.html", + context, + ) +} + +pub async fn update( + id: i64, + wallet: &Wallet, + form: HashMap, +) -> Response { + let error = |e: &str| { + update_form_feedback(id, wallet, form.clone(), Some(e.to_string())) + }; + + match validation::category::update(&form) { + Some(update_category) => { + if db::categories::update(&wallet.pool, id, &update_category).await + { + utils::redirect(&format!("/categories?highlight={}", id)) + } else { + error("Erreur serveur").await + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn delete(id: i64, wallet: &Wallet) -> Response { + if db::categories::delete(&wallet.pool, id).await { + utils::redirect("/categories") + } else { + update_form_feedback( + id, + wallet, + HashMap::new(), + Some("Erreur serveur".to_string()), + ) + .await + } +} diff --git a/src/controller/error.rs b/src/controller/error.rs new file mode 100644 index 0000000..8dad16b --- /dev/null +++ b/src/controller/error.rs @@ -0,0 +1,31 @@ +use hyper::header::CACHE_CONTROL; +use hyper::{Body, Response}; +use std::collections::HashMap; +use tera::{Context, Tera}; + +use crate::controller::utils; +use crate::controller::wallet::Wallet; + +pub fn error(wallet: &Wallet, title: &str, message: &str) -> Response { + utils::with_header( + Response::new( + template(&wallet.assets, &wallet.templates, title, message).into(), + ), + CACHE_CONTROL, + "no-cache", + ) +} + +pub fn template( + assets: &HashMap, + templates: &Tera, + title: &str, + message: &str, +) -> String { + let mut context = Context::new(); + context.insert("title", title); + context.insert("message", message); + context.insert("assets", assets); + + templates.render("error.html", &context).unwrap() +} diff --git a/src/controller/incomes.rs b/src/controller/incomes.rs new file mode 100644 index 0000000..ea7f1cf --- /dev/null +++ b/src/controller/incomes.rs @@ -0,0 +1,221 @@ +use chrono::Datelike; +use chrono::Utc; +use hyper::{Body, Response}; +use std::collections::HashMap; +use tera::Context; + +use crate::controller::utils; +use crate::controller::wallet::Wallet; +use crate::db; +use crate::queries; +use crate::templates; +use crate::validation; + +static PER_PAGE: i64 = 10; + +pub async fn table(wallet: &Wallet, query: queries::Incomes) -> Response { + let page = query.page.unwrap_or(1); + let count = db::incomes::count(&wallet.pool).await; + let incomes = db::incomes::list(&wallet.pool, page, PER_PAGE).await; + let max_page = (count as f32 / PER_PAGE as f32).ceil() as i64; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Incomes); + context.insert("connected_user", &wallet.user); + context.insert("incomes", &incomes); + context.insert("page", &page); + context.insert("max_page", &max_page); + context.insert("highlight", &query.highlight); + + utils::template( + &wallet.assets, + &wallet.templates, + "income/table.html", + context, + ) +} + +static MONTHS: [&str; 12] = [ + "Janvier", + "Février", + "Mars", + "Avril", + "Mai", + "Juin", + "Juillet", + "Août", + "Septembre", + "Octobre", + "Novembre", + "Décembre", +]; + +pub async fn create_form( + wallet: &Wallet, + query: queries::Incomes, +) -> Response { + create_form_feedback(wallet, query, HashMap::new(), None).await +} + +async fn create_form_feedback( + wallet: &Wallet, + query: queries::Incomes, + form: HashMap, + error: Option, +) -> Response { + let users = db::users::list(&wallet.pool).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Incomes); + context.insert("connected_user", &wallet.user); + context.insert("users", &users); + context.insert("query", &query); + context.insert("current_month", &Utc::today().naive_utc().month()); + context.insert("months", &MONTHS); + context.insert("form", &form); + context.insert("error", &error); + + utils::template( + &wallet.assets, + &wallet.templates, + "income/create.html", + context, + ) +} + +pub async fn create( + wallet: &Wallet, + query: queries::Incomes, + form: HashMap, +) -> Response { + let error = |e: &str| { + create_form_feedback(wallet, query, form.clone(), Some(e.to_string())) + }; + + match validation::income::create(&form) { + Some(income) => { + if !db::incomes::defined_at( + &wallet.pool, + income.user_id, + income.month, + income.year, + ) + .await + .is_empty() + { + error("Un revenu est déjà défini à cette date.").await + } else { + match db::incomes::create(&wallet.pool, &income).await { + Some(id) => { + let row = db::incomes::get_row(&wallet.pool, id).await; + let page = (row - 1) / PER_PAGE + 1; + utils::redirect(&format!( + "/incomes?page={}&highlight={}", + page, id + )) + } + None => error("Erreur serveur").await, + } + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn update_form( + id: i64, + wallet: &Wallet, + query: queries::Incomes, +) -> Response { + update_form_feedback(id, wallet, query, HashMap::new(), None).await +} + +async fn update_form_feedback( + id: i64, + wallet: &Wallet, + query: queries::Incomes, + form: HashMap, + error: Option, +) -> Response { + let users = db::users::list(&wallet.pool).await; + let income = db::incomes::get(&wallet.pool, id).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Incomes); + context.insert("connected_user", &wallet.user); + context.insert("users", &users); + context.insert("id", &id); + context.insert("income", &income); + context.insert("query", &query); + context.insert("months", &MONTHS); + context.insert("form", &form); + context.insert("error", &error); + + utils::template( + &wallet.assets, + &wallet.templates, + "income/update.html", + context, + ) +} + +pub async fn update( + id: i64, + wallet: &Wallet, + query: queries::Incomes, + form: HashMap, +) -> Response { + let error = |e: &str| { + update_form_feedback( + id, + wallet, + query, + form.clone(), + Some(e.to_string()), + ) + }; + + match validation::income::update(&form) { + Some(income) => { + let existing_incomes = db::incomes::defined_at( + &wallet.pool, + income.user_id, + income.month, + income.year, + ) + .await; + if existing_incomes.into_iter().any(|eid| eid != id) { + error("Un revenu est déjà défini à cette date.").await + } else if db::incomes::update(&wallet.pool, id, &income).await { + let row = db::incomes::get_row(&wallet.pool, id).await; + let page = (row - 1) / PER_PAGE + 1; + utils::redirect(&format!( + "/incomes?page={}&highlight={}", + page, id + )) + } else { + error("Erreur serveur").await + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn delete( + id: i64, + wallet: &Wallet, + query: queries::Incomes, +) -> Response { + if db::incomes::delete(&wallet.pool, id).await { + utils::redirect(&format!("/incomes?page={}", query.page.unwrap_or(1))) + } else { + update_form_feedback( + id, + wallet, + query, + HashMap::new(), + Some("Erreur serveur".to_string()), + ) + .await + } +} diff --git a/src/controller/login.rs b/src/controller/login.rs new file mode 100644 index 0000000..ea9db57 --- /dev/null +++ b/src/controller/login.rs @@ -0,0 +1,86 @@ +use bcrypt; +use hyper::{Body, Response}; +use sqlx::sqlite::SqlitePool; +use std::collections::HashMap; +use tera::{Context, Tera}; +use uuid::Uuid; + +use crate::controller::wallet::Wallet; +use crate::controller::{error, utils}; +use crate::db; +use crate::model::config::Config; +use crate::model::user::User; +use crate::validation; + +pub async fn page( + assets: &HashMap, + templates: &Tera, + error: Option, +) -> Response { + let connected_user: Option = None; + + let mut context = Context::new(); + context.insert("connected_user", &connected_user); + context.insert("error", &error); + + utils::template(assets, templates, "login.html", context) +} + +pub async fn login( + config: Config, + assets: &HashMap, + templates: &Tera, + form: HashMap, + pool: SqlitePool, +) -> Response { + let not_authorized = page( + assets, + templates, + Some("Vous n’êtes pas autorisé à vous connecter.".to_string()), + ) + .await; + let server_error = + page(assets, templates, Some("Erreur serveur.".to_string())).await; + match validation::login::login(&form) { + Some(login) => { + match db::users::get_password_hash(&pool, login.email.clone()).await + { + Some(hash) => match bcrypt::verify(login.password, &hash) { + Ok(true) => { + let login_token = Uuid::new_v4(); + if db::users::set_login_token( + &pool, + login.email, + login_token.clone().to_string(), + ) + .await + { + utils::with_login_cookie( + config, + login_token, + utils::redirect("/"), + ) + } else { + server_error + } + } + Ok(false) => not_authorized, + Err(err) => { + error!("Error verifying bcrypt password: {:?}", err); + server_error + } + }, + None => not_authorized, + } + } + None => not_authorized, + } +} + +pub async fn logout(config: Config, wallet: &Wallet) -> Response { + if db::users::remove_login_token(&wallet.pool, wallet.user.id).await { + utils::with_logout_cookie(config, utils::redirect("/")) + } else { + error::error(&wallet, "Erreur serveur", "Erreur serveur") + } +} diff --git a/src/controller/mod.rs b/src/controller/mod.rs new file mode 100644 index 0000000..e2ef561 --- /dev/null +++ b/src/controller/mod.rs @@ -0,0 +1,9 @@ +pub mod balance; +pub mod categories; +pub mod error; +pub mod incomes; +pub mod login; +pub mod payments; +pub mod statistics; +pub mod utils; +pub mod wallet; diff --git a/src/controller/payments.rs b/src/controller/payments.rs new file mode 100644 index 0000000..ab4bd92 --- /dev/null +++ b/src/controller/payments.rs @@ -0,0 +1,227 @@ +use hyper::header::CONTENT_TYPE; +use hyper::{Body, Response}; +use std::collections::HashMap; +use tera::Context; + +use crate::controller::utils; +use crate::controller::wallet::Wallet; +use crate::db; +use crate::model::frequency::Frequency; +use crate::queries; +use crate::templates; +use crate::validation; + +static PER_PAGE: i64 = 10; + +pub async fn table( + wallet: &Wallet, + query: queries::Payments, +) -> Response { + let page = query.page.unwrap_or(1); + let count = db::payments::count(&wallet.pool, &query).await; + let payments = + db::payments::list_for_table(&wallet.pool, &query, PER_PAGE).await; + let max_page = (count.count as f32 / PER_PAGE as f32).ceil() as i64; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Payments); + context.insert("connected_user", &wallet.user); + context.insert("payments", &payments); + context.insert("page", &page); + context.insert("max_page", &max_page); + context.insert("query", &query); + context.insert("count", &count.count); + context.insert("total_cost", &count.total_cost); + + utils::template( + &wallet.assets, + &wallet.templates, + "payment/table.html", + context, + ) +} + +pub async fn create_form( + wallet: &Wallet, + query: queries::Payments, +) -> Response { + create_form_feedback(wallet, query, HashMap::new(), None).await +} + +async fn create_form_feedback( + wallet: &Wallet, + query: queries::Payments, + form: HashMap, + error: Option, +) -> Response { + let users = db::users::list(&wallet.pool).await; + let categories = db::categories::list(&wallet.pool).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Payments); + context.insert("connected_user", &wallet.user); + context.insert("users", &users); + context.insert("categories", &categories); + context.insert("query", &query); + context.insert("form", &form); + context.insert("error", &error); + + utils::template( + &wallet.assets, + &wallet.templates, + "payment/create.html", + context, + ) +} + +pub async fn create( + wallet: &Wallet, + query: queries::Payments, + form: HashMap, +) -> Response { + let error = |e: &str| { + create_form_feedback(wallet, query, form.clone(), Some(e.to_string())) + }; + + match validation::payment::create(&form) { + Some(create_payment) => { + match db::payments::create(&wallet.pool, &create_payment).await { + Some(id) => { + let row = db::payments::get_row( + &wallet.pool, + id, + create_payment.frequency, + ) + .await; + let page = (row - 1) / PER_PAGE + 1; + let query = queries::Payments { + page: Some(page), + search: None, + frequency: Some(create_payment.frequency), + highlight: Some(id), + }; + utils::redirect(&format!( + "/{}", + queries::payments_url(query) + )) + } + None => error("Erreur serveur.").await, + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn update_form( + id: i64, + wallet: &Wallet, + query: queries::Payments, +) -> Response { + update_form_feedback(id, wallet, query, HashMap::new(), None).await +} + +async fn update_form_feedback( + id: i64, + wallet: &Wallet, + query: queries::Payments, + form: HashMap, + error: Option, +) -> Response { + let payment = db::payments::get_for_form(&wallet.pool, id).await; + let users = db::users::list(&wallet.pool).await; + let categories = db::categories::list(&wallet.pool).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Payments); + context.insert("connected_user", &wallet.user); + context.insert("id", &id); + context.insert("payment", &payment); + context.insert("users", &users); + context.insert("categories", &categories); + context.insert("query", &query); + context.insert("form", &form); + context.insert("error", &error); + + utils::template( + &wallet.assets, + &wallet.templates, + "payment/update.html", + context, + ) +} + +pub async fn update( + id: i64, + wallet: &Wallet, + query: queries::Payments, + form: HashMap, +) -> Response { + let error = |e: &str| { + update_form_feedback( + id, + wallet, + query.clone(), + form.clone(), + Some(e.to_string()), + ) + }; + + match validation::payment::update(&form) { + Some(update_payment) => { + if db::payments::update(&wallet.pool, id, &update_payment).await { + let frequency = query.frequency.unwrap_or(Frequency::Punctual); + let row = + db::payments::get_row(&wallet.pool, id, frequency).await; + let page = (row - 1) / PER_PAGE + 1; + let query = queries::Payments { + page: Some(page), + search: None, + frequency: Some(frequency), + highlight: Some(id), + }; + utils::redirect(&format!("/{}", queries::payments_url(query))) + } else { + error("Erreur serveur").await + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn delete( + id: i64, + wallet: &Wallet, + query: queries::Payments, +) -> Response { + if db::payments::delete(&wallet.pool, id).await { + let query = queries::Payments { + highlight: None, + ..query + }; + utils::redirect(&format!("/{}", queries::payments_url(query))) + } else { + update_form_feedback( + id, + wallet, + query, + HashMap::new(), + Some("Erreur serveur".to_string()), + ) + .await + } +} + +pub async fn search_category( + wallet: &Wallet, + query: queries::PaymentCategory, +) -> Response { + match db::payments::search_category(&wallet.pool, query.payment_name).await + { + Some(category_id) => utils::with_header( + Response::new(format!("{}", category_id).into()), + CONTENT_TYPE, + "application/json", + ), + None => utils::not_found(), + } +} diff --git a/src/controller/statistics.rs b/src/controller/statistics.rs new file mode 100644 index 0000000..38a5787 --- /dev/null +++ b/src/controller/statistics.rs @@ -0,0 +1,30 @@ +use hyper::{Body, Response}; +use tera::Context; + +use crate::controller::utils; +use crate::controller::wallet::Wallet; +use crate::db; +use crate::templates; + +pub async fn get(wallet: &Wallet) -> Response { + let categories = db::categories::list(&wallet.pool).await; + let payments = db::payments::list_for_stats(&wallet.pool).await; + let incomes = db::incomes::total_each_month(&wallet.pool).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Statistics); + context.insert("connected_user", &wallet.user); + context.insert( + "json_categories", + &serde_json::to_string(&categories).unwrap(), + ); + context.insert("json_payments", &serde_json::to_string(&payments).unwrap()); + context.insert("json_incomes", &serde_json::to_string(&incomes).unwrap()); + + utils::template( + &wallet.assets, + &wallet.templates, + "statistics.html", + context, + ) +} diff --git a/src/controller/utils.rs b/src/controller/utils.rs new file mode 100644 index 0000000..225f8a4 --- /dev/null +++ b/src/controller/utils.rs @@ -0,0 +1,119 @@ +use hyper::header::{ + HeaderName, HeaderValue, CACHE_CONTROL, CONTENT_TYPE, LOCATION, SET_COOKIE, +}; +use hyper::{Body, Response, StatusCode}; +use std::collections::HashMap; +use tera::{Context, Tera}; +use tokio::fs::File; +use tokio_util::codec::{BytesCodec, FramedRead}; +use uuid::Uuid; + +use crate::controller::error; +use crate::model::config::Config; + +pub fn with_header( + response: Response, + name: HeaderName, + value: &str, +) -> Response { + with_headers(response, vec![(name, value)]) +} + +pub fn with_headers( + response: Response, + headers: Vec<(HeaderName, &str)>, +) -> Response { + let mut response = response; + let response_headers = response.headers_mut(); + for (name, value) in headers { + response_headers.insert(name, HeaderValue::from_str(value).unwrap()); + } + response +} + +pub fn with_login_cookie( + config: Config, + login_token: Uuid, + response: Response, +) -> Response { + let cookie = format!( + "TOKEN={}; SameSite=Strict; HttpOnly; Max-Age=86400{}", + login_token, + if config.secure_cookies { + "; Secure" + } else { + "" + } + ); + + with_header(response, SET_COOKIE, &cookie) +} + +pub fn with_logout_cookie( + config: Config, + response: Response, +) -> Response { + let cookie = format!( + "TOKEN=; SameSite=Strict; HttpOnly; Max-Age=0{}", + if config.secure_cookies { + "; Secure" + } else { + "" + } + ); + + with_header(response, SET_COOKIE, &cookie) +} + +pub fn template( + assets: &HashMap, + templates: &Tera, + path: &str, + context: Context, +) -> Response { + let mut context = context.clone(); + context.insert("assets", assets); + + let response = match templates.render(path, &context) { + Ok(template) => Response::new(template.into()), + Err(err) => Response::new( + error::template( + assets, + templates, + "Erreur serveur", + &format!( + "Erreur lors de la préparation de la page : {:?}", + err + ), + ) + .into(), + ), + }; + + with_headers( + response, + vec![(CONTENT_TYPE, "text/html"), (CACHE_CONTROL, "no-cache")], + ) +} + +pub fn redirect(uri: &str) -> Response { + let mut response = Response::default(); + *response.status_mut() = StatusCode::MOVED_PERMANENTLY; + with_headers(response, vec![(LOCATION, uri), (CACHE_CONTROL, "no-cache")]) +} + +pub fn not_found() -> Response { + let mut response = Response::default(); + *response.status_mut() = StatusCode::NOT_FOUND; + response +} + +pub async fn file(filename: &str) -> Response { + if let Ok(file) = File::open(filename).await { + let stream = FramedRead::new(file, BytesCodec::new()); + let body = Body::wrap_stream(stream); + with_header(Response::new(body), CACHE_CONTROL, "max-age=3153600000") + } else { + not_found() + } +} diff --git a/src/controller/wallet.rs b/src/controller/wallet.rs new file mode 100644 index 0000000..2a4a593 --- /dev/null +++ b/src/controller/wallet.rs @@ -0,0 +1,13 @@ +use sqlx::sqlite::SqlitePool; +use std::collections::HashMap; +use tera::Tera; + +use crate::model::user::User; + +#[derive(Clone)] +pub struct Wallet { + pub pool: SqlitePool, + pub assets: HashMap, + pub templates: Tera, + pub user: User, +} diff --git a/src/db/categories.rs b/src/db/categories.rs new file mode 100644 index 0000000..05b1323 --- /dev/null +++ b/src/db/categories.rs @@ -0,0 +1,132 @@ +use sqlx::sqlite::SqlitePool; + +use crate::model::category::{Category, Create, Update}; + +pub async fn list(pool: &SqlitePool) -> Vec { + let res = sqlx::query_as::<_, Category>( + r#" +SELECT + id, + name, + color +FROM + categories +WHERE + deleted_at IS NULL +ORDER BY + name + "#, + ) + .fetch_all(pool) + .await; + + match res { + Ok(categories) => categories, + Err(err) => { + error!("Error listing categories: {:?}", err); + vec![] + } + } +} + +pub async fn get(pool: &SqlitePool, id: i64) -> Option { + let query = r#" +SELECT + id, + name, + color +FROM + categories +WHERE + id = ? + AND deleted_at IS NULL + "#; + + let res = sqlx::query_as::<_, Category>(query) + .bind(id) + .fetch_one(pool) + .await; + + match res { + Ok(p) => Some(p), + Err(err) => { + error!("Error looking for category {}: {:?}", id, err); + None + } + } +} + +pub async fn create(pool: &SqlitePool, c: &Create) -> Option { + let res = sqlx::query( + r#" +INSERT INTO + categories(name, color, created_at) +VALUES + (?, ?, datetime()) + "#, + ) + .bind(c.name.clone()) + .bind(c.color.clone()) + .execute(pool) + .await; + + match res { + Ok(x) => Some(x.last_insert_rowid()), + Err(err) => { + error!("Error creating category: {:?}", err); + None + } + } +} + +pub async fn update(pool: &SqlitePool, id: i64, c: &Update) -> bool { + let res = sqlx::query( + r#" +UPDATE + categories +SET + name = ?, + color = ?, + updated_at = datetime() +WHERE + id = ? + "#, + ) + .bind(c.name.clone()) + .bind(c.color.clone()) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error updating category {}: {:?}", id, err); + false + } + } +} + +pub async fn delete(pool: &SqlitePool, id: i64) -> bool { + let res = sqlx::query( + r#" +UPDATE + categories +SET + deleted_at = datetime() +WHERE + id = ? + "#, + ) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error deleting category {}: {:?}", id, err); + false + } + } +} diff --git a/src/db/incomes.rs b/src/db/incomes.rs new file mode 100644 index 0000000..cbbfce7 --- /dev/null +++ b/src/db/incomes.rs @@ -0,0 +1,494 @@ +use chrono::NaiveDate; +use sqlx::error::Error; +use sqlx::sqlite::{SqlitePool, SqliteRow}; +use sqlx_core::row::Row; +use std::collections::HashMap; +use std::iter::FromIterator; + +use crate::model::income::{Create, Form, Stat, Table, Update}; +use crate::model::report::Report; + +pub async fn count(pool: &SqlitePool) -> i64 { + let query = r#" +SELECT + COUNT(*) AS count +FROM + incomes +WHERE + incomes.deleted_at IS NULL + "#; + + let res = sqlx::query(&query) + .map(|row: SqliteRow| row.get("count")) + .fetch_one(pool) + .await; + + match res { + Ok(count) => count, + Err(err) => { + error!("Error counting incomes: {:?}", err); + 0 + } + } +} + +pub async fn list(pool: &SqlitePool, page: i64, per_page: i64) -> Vec { + let query = r#" +SELECT + incomes.id, + users.name AS user, + strftime('%m/%Y', incomes.date) AS date, + incomes.amount +FROM + incomes +INNER JOIN + users +ON + incomes.user_id = users.id +WHERE + incomes.deleted_at IS NULL +ORDER BY + incomes.date DESC +LIMIT ? +OFFSET ? + "#; + + let res = sqlx::query_as::<_, Table>(query) + .bind(per_page) + .bind((page - 1) * per_page) + .fetch_all(pool) + .await; + + match res { + Ok(incomes) => incomes, + Err(err) => { + error!("Error listing incomes: {:?}", err); + vec![] + } + } +} + +pub async fn get_row(pool: &SqlitePool, id: i64) -> i64 { + let query = r#" +SELECT + row +FROM ( + SELECT + ROW_NUMBER () OVER (ORDER BY date DESC) AS row, + id + FROM + incomes + WHERE + deleted_at IS NULL +) +WHERE + id = ? + "#; + + let res = sqlx::query(query) + .bind(id) + .map(|row: SqliteRow| row.get("row")) + .fetch_one(pool) + .await; + + match res { + Ok(count) => count, + Err(err) => { + error!("Error getting income row: {:?}", err); + 1 + } + } +} + +pub async fn get(pool: &SqlitePool, id: i64) -> Option { + let query = r#" +SELECT + id, + amount, + user_id, + CAST(strftime('%m', date) AS INTEGER) as month, + CAST(strftime('%Y', date) AS INTEGER) as year +FROM + incomes +WHERE + id = ? + AND deleted_at IS NULL + "#; + + let res = sqlx::query_as::<_, Form>(query) + .bind(id) + .fetch_one(pool) + .await; + + match res { + Ok(p) => Some(p), + Err(err) => { + error!("Error looking for income {}: {:?}", id, err); + None + } + } +} + +pub async fn create(pool: &SqlitePool, i: &Create) -> Option { + let res = sqlx::query( + r#" +INSERT INTO + incomes(user_id, date, amount, created_at) +VALUES + (?, ?, ?, datetime()) + "#, + ) + .bind(i.user_id) + .bind(NaiveDate::from_ymd(i.year, i.month, 1)) + .bind(i.amount) + .execute(pool) + .await; + + match res { + Ok(x) => Some(x.last_insert_rowid()), + Err(err) => { + error!("Error creating income: {:?}", err); + None + } + } +} + +pub async fn defined_at( + pool: &SqlitePool, + user_id: i64, + month: u32, + year: i32, +) -> Vec { + let query = r#" +SELECT + id +FROM + incomes +WHERE + user_id = ? + AND date = ? + AND deleted_at IS NULL + "#; + + let res = sqlx::query(&query) + .bind(user_id) + .bind(NaiveDate::from_ymd(year, month, 1)) + .map(|row: SqliteRow| row.get("id")) + .fetch_all(pool) + .await; + + match res { + Ok(ids) => ids, + Err(Error::RowNotFound) => vec![], + Err(err) => { + error!("Error looking if income is defined: {:?}", err); + vec![] + } + } +} + +pub async fn update(pool: &SqlitePool, id: i64, i: &Update) -> bool { + let res = sqlx::query( + r#" +UPDATE + incomes +SET + user_id = ?, + date = ?, + amount = ?, + updated_at = datetime() +WHERE + id = ? + "#, + ) + .bind(i.user_id) + .bind(NaiveDate::from_ymd(i.year, i.month, 1)) + .bind(i.amount) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error updating income {}: {:?}", id, err); + false + } + } +} + +pub async fn delete(pool: &SqlitePool, id: i64) -> bool { + let res = sqlx::query( + r#" +UPDATE + incomes +SET + deleted_at = datetime() +WHERE + id = ? + "#, + ) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error deleting income {}: {:?}", id, err); + false + } + } +} + +pub async fn defined_for_all(pool: &SqlitePool) -> Option { + let res = sqlx::query( + r#" +SELECT + (CASE COUNT(users.id) == COUNT(min_income.date) + WHEN 1 THEN MIN(min_income.date) + ELSE NULL + END) AS date +FROM + users +LEFT OUTER JOIN ( + SELECT + user_id, + MIN(date) AS date + FROM + incomes + WHERE + deleted_at IS NULL + GROUP BY + user_id +) min_income +ON + users.id = min_income.user_id; + "#, + ) + .map(|row: SqliteRow| row.get("date")) + .fetch_one(pool) + .await; + + match res { + Ok(d) => d, + Err(err) => { + error!("Error looking for incomes defined for all: {:?}", err); + None + } + } +} + +pub async fn cumulative( + pool: &SqlitePool, + from: NaiveDate, +) -> HashMap { + let res = sqlx::query(&cumulative_query(from)) + .map(|row: SqliteRow| (row.get("user_id"), row.get("income"))) + .fetch_all(pool) + .await; + + match res { + Ok(incomes) => HashMap::from_iter(incomes), + Err(err) => { + error!("Error computing cumulative income: {:?}", err); + HashMap::new() + } + } +} + +/// Select cumulative income of users from the given date and until now. +/// +/// Associate each month income to its start and end bounds, +/// then compute the total income of each period, +/// sum it to get the final result. +/// +/// Considering each month to be 365 / 12 days long. +fn cumulative_query(from: NaiveDate) -> String { + format!( + r#" +SELECT + users.id AS user_id, + COALESCE(incomes.income, 0) AS income +FROM + users +LEFT OUTER JOIN ( + SELECT + user_id, + CAST(ROUND(SUM(count)) AS INTEGER) AS income + FROM ( + SELECT + I1.user_id, + ((JULIANDAY(MIN(I2.date)) - JULIANDAY(I1.date)) * I1.amount * 12 / 365) AS count + FROM + ({}) AS I1 + INNER JOIN + ({}) AS I2 + ON + I2.date > I1.date + AND I2.user_id == I1.user_id + GROUP BY + I1.date, I1.user_id + ) + GROUP BY + user_id +) incomes +ON + users.id = incomes.user_id + "#, + bounded_query(">".to_string(), from.format("%Y-%m-%d").to_string()), + bounded_query("<".to_string(), "date()".to_string()) + ) +} + +/// Select bounded incomes to the operator and date. +/// +/// It filters incomes according to the operator and date, +/// and adds the income at this date. +fn bounded_query(op: String, date: String) -> String { + format!( + r#" +SELECT + user_id, + date, + amount +FROM ( + SELECT + user_id, + {} AS date, + amount, + MAX(date) AS max_date + FROM + incomes + WHERE + date <= {} + AND deleted_at IS NULL + GROUP BY + user_id +) UNION +SELECT + user_id, + date, + amount +FROM + incomes +WHERE + date {} {} + AND deleted_at IS NULL + "#, + date, date, op, date + ) +} + +/// Select total income each month. +/// +/// For each month, from the first defined income and until now, +/// compute the total income of the users. +pub async fn total_each_month(pool: &SqlitePool) -> Vec { + let query = r#" +WITH RECURSIVE dates(date) AS ( + VALUES(( + SELECT + strftime('%Y-%m-01', MIN(date)) + FROM + incomes + WHERE + deleted_at IS NULL + )) + UNION ALL + SELECT + date(date, '+1 month') + FROM + dates + WHERE + date < date(date(), '-1 month') +) +SELECT + strftime('%Y-%m-01', dates.date) AS date, + ( + SELECT + SUM(amount) AS amount + FROM ( + SELECT ( + SELECT + amount + FROM + incomes + WHERE + user_id = users.id + AND date < date(dates.date, '+1 month') + AND deleted_at IS NULL + ORDER BY + date DESC + LIMIT + 1 + ) AS amount + FROM + users + ) + ) AS amount +FROM + dates; + "#; + + let res = sqlx::query_as::<_, Stat>(query).fetch_all(pool).await; + + match res { + Ok(xs) => xs, + Err(err) => { + error!("Error listing incomes for statistics: {:?}", err); + vec![] + } + } +} + +pub async fn last_week(pool: &SqlitePool) -> Vec { + let query = r#" +SELECT + strftime('%m/%Y', incomes.date) AS date, + users.name AS name, + incomes.amount AS amount, + (CASE + WHEN + incomes.deleted_at IS NOT NULL + THEN + 'Deleted' + WHEN + incomes.updated_at IS NOT NULL + AND incomes.created_at < date('now', 'weekday 0', '-13 days') + THEN + 'Updated' + ELSE + 'Created' + END) AS action +FROM + incomes +INNER JOIN + users +ON + incomes.user_id = users.id +WHERE + ( + incomes.created_at >= date('now', 'weekday 0', '-13 days') + AND incomes.created_at < date('now', 'weekday 0', '-6 days') + ) OR ( + incomes.updated_at >= date('now', 'weekday 0', '-13 days') + AND incomes.updated_at < date('now', 'weekday 0', '-6 days') + ) OR ( + incomes.deleted_at >= date('now', 'weekday 0', '-13 days') + AND incomes.deleted_at < date('now', 'weekday 0', '-6 days') + ) +ORDER BY + incomes.date + "#; + + let res = sqlx::query_as::<_, Report>(query).fetch_all(pool).await; + + match res { + Ok(payments) => payments, + Err(err) => { + error!("Error listing payments for report: {:?}", err); + vec![] + } + } +} diff --git a/src/db/jobs.rs b/src/db/jobs.rs new file mode 100644 index 0000000..88c2005 --- /dev/null +++ b/src/db/jobs.rs @@ -0,0 +1,56 @@ +use sqlx::error::Error; +use sqlx::sqlite::SqlitePool; + +use crate::model::job::Job; + +pub async fn should_run(pool: &SqlitePool, job: Job) -> bool { + let run_from = match job { + Job::WeeklyReport => "date('now', 'weekday 0', '-6 days')", + Job::MonthlyPayment => "date('now', 'start of month')", + }; + + let query = format!( + r#" +SELECT + 1 +FROM + jobs +WHERE + name = ? + AND last_execution < {} + "#, + run_from + ); + + let res = sqlx::query(&query).bind(job).fetch_one(pool).await; + + match res { + Ok(_) => true, + Err(Error::RowNotFound) => false, + Err(err) => { + error!("Error looking if job should run: {:?}", err); + false + } + } +} + +pub async fn actualize_last_execution(pool: &SqlitePool, job: Job) -> () { + let query = r#" +UPDATE + jobs +SET + last_execution = datetime() +WHERE + name = ? + "#; + + let res = sqlx::query(query).bind(job).execute(pool).await; + + match res { + Ok(_) => (), + Err(err) => { + error!("Error actualizing job last execution: {:?}", err); + () + } + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..a0aa3dc --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,6 @@ +pub mod categories; +pub mod incomes; +pub mod jobs; +pub mod payments; +pub mod users; +mod utils; diff --git a/src/db/payments.rs b/src/db/payments.rs new file mode 100644 index 0000000..0197375 --- /dev/null +++ b/src/db/payments.rs @@ -0,0 +1,525 @@ +use sqlx::error::Error; +use sqlx::sqlite::{Sqlite, SqliteArguments}; +use sqlx::sqlite::{SqlitePool, SqliteRow}; +use sqlx::FromRow; +use sqlx_core::row::Row; +use std::collections::HashMap; +use std::iter::FromIterator; + +use crate::db::utils; +use crate::model::frequency::Frequency; +use crate::model::payment; +use crate::model::report::Report; +use crate::queries; +use crate::utils::text; + +#[derive(FromRow)] +pub struct Count { + pub count: i64, + pub total_cost: i64, +} + +pub async fn count( + pool: &SqlitePool, + payment_query: &queries::Payments, +) -> Count { + let search = payment_query.search.clone().unwrap_or("".to_string()); + + let query = format!( + r#" +SELECT + COUNT(*) AS count, + SUM(payments.cost) AS total_cost +FROM + payments +INNER JOIN + users ON users.id = payments.user_id +INNER JOIN + categories ON categories.id = payments.category_id +WHERE + payments.deleted_at IS NULL + AND payments.frequency = ? + {} + "#, + search_query(search.clone()) + ); + + let res = bind_search( + sqlx::query_as::<_, Count>(&query) + .bind(payment_query.frequency.unwrap_or(Frequency::Punctual)), + search, + ) + .fetch_one(pool) + .await; + + match res { + Ok(count) => count, + Err(err) => { + error!("Error counting payments: {:?}", err); + Count { + count: 0, + total_cost: 0, + } + } + } +} + +pub async fn list_for_table( + pool: &SqlitePool, + payment_query: &queries::Payments, + per_page: i64, +) -> Vec { + let offset = (payment_query.page.unwrap_or(1) - 1) * per_page; + let search = payment_query.search.clone().unwrap_or("".to_string()); + + let query = format!( + r#" +SELECT + payments.id, + payments.name, + payments.cost, + users.name AS user, + categories.name AS category_name, + categories.color AS category_color, + strftime('%d/%m/%Y', date) AS date, + payments.frequency AS frequency +FROM + payments +INNER JOIN + users ON users.id = payments.user_id +INNER JOIN + categories ON categories.id = payments.category_id +WHERE + payments.deleted_at IS NULL + AND payments.frequency = ? + {} +ORDER BY + payments.date DESC +LIMIT ? +OFFSET ? + "#, + search_query(search.clone()) + ); + + let res = bind_search( + sqlx::query_as::<_, payment::Table>(&query) + .bind(payment_query.frequency.unwrap_or(Frequency::Punctual)), + search, + ) + .bind(per_page) + .bind(offset) + .fetch_all(pool) + .await; + + match res { + Ok(payments) => payments, + Err(err) => { + error!("Error listing payments: {:?}", err); + vec![] + } + } +} + +fn search_query(search: String) -> String { + let payments_name = utils::format_key_for_search("payments.name"); + let users_name = utils::format_key_for_search("users.name"); + let categories_name = utils::format_key_for_search("categories.name"); + + search + .split_ascii_whitespace() + .map(|_| { + format!( + r#" +AND ( + {} LIKE ? + OR payments.cost LIKE ? + OR {} LIKE ? + OR {} LIKE ? + OR strftime('%d/%m/%Y', date) LIKE ? +) + "#, + payments_name, users_name, categories_name + ) + }) + .collect::>() + .join(" ") +} + +fn bind_search<'a, Row: FromRow<'a, SqliteRow>>( + query: sqlx::query::QueryAs<'a, Sqlite, Row, SqliteArguments<'a>>, + search: String, +) -> sqlx::query::QueryAs<'a, Sqlite, Row, SqliteArguments<'a>> { + search.split_ascii_whitespace().fold(query, |q, word| { + let s = format!("%{}%", text::format_search(&word.to_string())); + q.bind(s.clone()) + .bind(s.clone()) + .bind(s.clone()) + .bind(s.clone()) + .bind(s.clone()) + }) +} + +pub async fn list_for_stats(pool: &SqlitePool) -> Vec { + let query = r#" +SELECT + strftime('%Y-%m-01', payments.date) AS start_date, + SUM(payments.cost) AS cost, + payments.category_id AS category_id +FROM + payments +WHERE + payments.deleted_at IS NULL + AND payments.frequency = 'Punctual' +GROUP BY + start_date, + payments.category_id; + "#; + + let result = sqlx::query_as::<_, payment::Stat>(query) + .fetch_all(pool) + .await; + + match result { + Ok(payments) => payments, + Err(err) => { + error!("Error listing payments for statistics: {:?}", err); + vec![] + } + } +} + +pub async fn get_row(pool: &SqlitePool, id: i64, frequency: Frequency) -> i64 { + let query = r#" +SELECT + row +FROM ( + SELECT + ROW_NUMBER () OVER (ORDER BY date DESC) AS row, + id + FROM + payments + WHERE + deleted_at IS NULL + AND frequency = ? +) +WHERE + id = ? + "#; + + let res = sqlx::query(query) + .bind(frequency) + .bind(id) + .map(|row: SqliteRow| row.get("row")) + .fetch_one(pool) + .await; + + match res { + Ok(count) => count, + Err(err) => { + error!("Error getting payment row: {:?}", err); + 1 + } + } +} + +pub async fn get_for_form(pool: &SqlitePool, id: i64) -> Option { + let query = r#" +SELECT + id, + name, + cost, + user_id, + category_id, + strftime('%Y-%m-%d', date) AS date, + frequency AS frequency +FROM + payments +WHERE + id = ? + AND deleted_at IS NULL + "#; + + let res = sqlx::query_as::<_, payment::Form>(query) + .bind(id) + .fetch_one(pool) + .await; + + match res { + Ok(p) => Some(p), + Err(err) => { + error!("Error looking for payment {}: {:?}", id, err); + None + } + } +} + +pub async fn create(pool: &SqlitePool, p: &payment::Create) -> Option { + let res = sqlx::query( + r#" +INSERT INTO + payments(name, cost, user_id, category_id, date, frequency, created_at) +VALUES + (?, ?, ?, ?, ?, ?, datetime()) + "#, + ) + .bind(p.name.clone()) + .bind(p.cost) + .bind(p.user_id) + .bind(p.category_id) + .bind(p.date) + .bind(p.frequency) + .execute(pool) + .await; + + match res { + Ok(x) => Some(x.last_insert_rowid()), + Err(err) => { + error!("Error creating payment: {:?}", err); + None + } + } +} + +pub async fn update(pool: &SqlitePool, id: i64, p: &payment::Update) -> bool { + let res = sqlx::query( + r#" +UPDATE + payments +SET + name = ?, + cost = ?, + user_id = ?, + category_id = ?, + date = ?, + updated_at = datetime() +WHERE + id = ? + "#, + ) + .bind(p.name.clone()) + .bind(p.cost) + .bind(p.user_id) + .bind(p.category_id) + .bind(p.date) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error updating payment {}: {:?}", id, err); + false + } + } +} + +pub async fn delete(pool: &SqlitePool, id: i64) -> bool { + let res = sqlx::query( + r#" +UPDATE + payments +SET + deleted_at = datetime() +WHERE + id = ? + "#, + ) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error deleting payment {}: {:?}", id, err); + false + } + } +} + +pub async fn search_category( + pool: &SqlitePool, + payment_name: String, +) -> Option { + let query = format!( + r#" +SELECT + category_id +FROM + payments +WHERE + deleted_at IS NULL + AND {} LIKE ? +ORDER BY + updated_at, created_at + "#, + utils::format_key_for_search("name") + ); + + let res = sqlx::query(&query) + .bind(text::format_search(&format!("%{}%", payment_name))) + .map(|row: SqliteRow| row.get("category_id")) + .fetch_one(pool) + .await; + + match res { + Ok(category) => Some(category), + Err(Error::RowNotFound) => None, + Err(err) => { + error!( + "Error looking for the category of {}: {:?}", + payment_name, err + ); + None + } + } +} + +pub async fn is_category_used(pool: &SqlitePool, category_id: i64) -> bool { + let query = r#" +SELECT + 1 +FROM + payments +WHERE + category_id = ? + AND deleted_at IS NULL +LIMIT + 1 + "#; + + let res = sqlx::query(&query).bind(category_id).fetch_one(pool).await; + + match res { + Ok(_) => true, + Err(Error::RowNotFound) => false, + Err(err) => { + error!( + "Error looking if category {} is used: {:?}", + category_id, err + ); + false + } + } +} + +pub async fn repartition(pool: &SqlitePool) -> HashMap { + let query = r#" +SELECT + users.id AS user_id, + COALESCE(payments.sum, 0) AS sum +FROM + users +LEFT OUTER JOIN ( + SELECT + user_id, + SUM(cost) AS sum + FROM + payments + WHERE + deleted_at IS NULL + AND frequency = 'Punctual' + GROUP BY + user_id +) payments +ON + users.id = payments.user_id"#; + + let res = sqlx::query(&query) + .map(|row: SqliteRow| (row.get("user_id"), row.get("sum"))) + .fetch_all(pool) + .await; + + match res { + Ok(costs) => HashMap::from_iter(costs), + Err(err) => { + error!("Error getting payments repartition: {:?}", err); + HashMap::new() + } + } +} + +pub async fn create_monthly_payments(pool: &SqlitePool) -> () { + let query = r#" +INSERT INTO + payments(name, cost, user_id, category_id, date, frequency, created_at) +SELECT + name, + cost, + user_id, + category_id, + date() AS date, + 'Punctual' AS frequency, + datetime() AS created_at +FROM + payments +WHERE + frequency = 'Monthly' + AND deleted_at IS NULL + "#; + + let res = sqlx::query(query).execute(pool).await; + + match res { + Ok(_) => (), + Err(err) => { + error!("Error creating monthly payments: {:?}", err); + () + } + } +} + +pub async fn last_week(pool: &SqlitePool) -> Vec { + let query = r#" +SELECT + strftime('%d/%m/%Y', payments.date) AS date, + (payments.name || ' (' || users.name || ')') AS name, + payments.cost AS amount, + (CASE + WHEN + payments.deleted_at IS NOT NULL + THEN + 'Deleted' + WHEN + payments.updated_at IS NOT NULL + AND payments.created_at < date('now', 'weekday 0', '-13 days') + THEN + 'Updated' + ELSE + 'Created' + END) AS action +FROM + payments +INNER JOIN + users +ON + payments.user_id = users.id +WHERE + payments.frequency = 'Punctual' + AND ( + ( + payments.created_at >= date('now', 'weekday 0', '-13 days') + AND payments.created_at < date('now', 'weekday 0', '-6 days') + ) OR ( + payments.updated_at >= date('now', 'weekday 0', '-13 days') + AND payments.updated_at < date('now', 'weekday 0', '-6 days') + ) OR ( + payments.deleted_at >= date('now', 'weekday 0', '-13 days') + AND payments.deleted_at < date('now', 'weekday 0', '-6 days') + ) + ) +ORDER BY + payments.date + "#; + + let res = sqlx::query_as::<_, Report>(query).fetch_all(pool).await; + + match res { + Ok(payments) => payments, + Err(err) => { + error!("Error listing payments for report: {:?}", err); + vec![] + } + } +} diff --git a/src/db/users.rs b/src/db/users.rs new file mode 100644 index 0000000..82326a9 --- /dev/null +++ b/src/db/users.rs @@ -0,0 +1,144 @@ +use sqlx::error::Error; +use sqlx::sqlite::{SqlitePool, SqliteRow}; +use sqlx_core::row::Row; + +use crate::model::user::User; + +pub async fn list(pool: &SqlitePool) -> Vec { + let res = sqlx::query_as::<_, User>( + r#" +SELECT + id, + name, + email +FROM + users +ORDER BY + name + "#, + ) + .fetch_all(pool) + .await; + + match res { + Ok(users) => users, + Err(err) => { + error!("Error listing users: {:?}", err); + vec![] + } + } +} + +pub async fn set_login_token( + pool: &SqlitePool, + email: String, + login_token: String, +) -> bool { + let res = sqlx::query( + r#" +UPDATE + users +SET + login_token = ?, + updated_at = datetime() +WHERE + email = ? + "#, + ) + .bind(login_token) + .bind(email) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error updating login token: {:?}", err); + false + } + } +} + +pub async fn remove_login_token(pool: &SqlitePool, id: i64) -> bool { + let res = sqlx::query( + r#" +UPDATE + users +SET + login_token = NULL, + updated_at = datetime() +WHERE + id = ? + "#, + ) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error removing login token: {:?}", err); + false + } + } +} + +pub async fn get_by_login_token( + pool: &SqlitePool, + login_token: String, +) -> Option { + let res = sqlx::query_as::<_, User>( + r#" +SELECT + id, + name, + email +FROM + users +WHERE + login_token = ? + "#, + ) + .bind(login_token) + .fetch_one(pool) + .await; + + match res { + Ok(user) => Some(user), + Err(Error::RowNotFound) => None, + Err(err) => { + error!("Error getting user from login token: {:?}", err); + None + } + } +} + +pub async fn get_password_hash( + pool: &SqlitePool, + email: String, +) -> Option { + let res = sqlx::query( + r#" +SELECT + password +FROM + users +WHERE + email = ? + "#, + ) + .bind(email) + .map(|row: SqliteRow| row.get("password")) + .fetch_one(pool) + .await; + + match res { + Ok(hash) => Some(hash), + Err(Error::RowNotFound) => None, + Err(err) => { + error!("Error getting password hash: {:?}", err); + None + } + } +} diff --git a/src/db/utils.rs b/src/db/utils.rs new file mode 100644 index 0000000..621a69c --- /dev/null +++ b/src/db/utils.rs @@ -0,0 +1,3 @@ +pub fn format_key_for_search(value: &str) -> String { + format!("replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(lower({}), 'à', 'a'), 'â', 'a'), 'ç', 'c'), 'è', 'e'), 'é', 'e'), 'ê', 'e'), 'ë', 'e'), 'î', 'i'), 'ï', 'i'), 'ô', 'o'), 'ù', 'u'), 'û', 'u'), 'ü', 'u')", value) +} diff --git a/src/jobs/jobs.rs b/src/jobs/jobs.rs new file mode 100644 index 0000000..3e54624 --- /dev/null +++ b/src/jobs/jobs.rs @@ -0,0 +1,28 @@ +use sqlx::sqlite::SqlitePool; +use tera::Tera; +use tokio::time::{delay_for, Duration}; + +use crate::db; +use crate::jobs::weekly_report; +use crate::model::config::Config; +use crate::model::job::Job; + +pub async fn start(config: Config, pool: SqlitePool, templates: Tera) -> () { + loop { + if db::jobs::should_run(&pool, Job::WeeklyReport).await { + info!("Starting weekly report job"); + if weekly_report::send(&config, &pool, &templates).await { + db::jobs::actualize_last_execution(&pool, Job::WeeklyReport) + .await; + } + } + if db::jobs::should_run(&pool, Job::MonthlyPayment).await { + info!("Starting monthly payment job"); + db::payments::create_monthly_payments(&pool).await; + db::jobs::actualize_last_execution(&pool, Job::MonthlyPayment) + .await; + } + // Sleeping 8 hours + delay_for(Duration::from_secs(8 * 60 * 60)).await; + } +} diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs new file mode 100644 index 0000000..be2ddac --- /dev/null +++ b/src/jobs/mod.rs @@ -0,0 +1,2 @@ +pub mod jobs; +mod weekly_report; diff --git a/src/jobs/weekly_report.rs b/src/jobs/weekly_report.rs new file mode 100644 index 0000000..819d30b --- /dev/null +++ b/src/jobs/weekly_report.rs @@ -0,0 +1,55 @@ +use sqlx::sqlite::SqlitePool; +use std::collections::HashMap; +use tera::{Context, Tera}; + +use crate::db; +use crate::mail; +use crate::model::config::Config; +use crate::payer; + +pub async fn send( + config: &Config, + pool: &SqlitePool, + templates: &Tera, +) -> bool { + match get_weekly_report(pool, templates).await { + Ok(report) => { + let users = db::users::list(pool).await; + mail::send( + config, + users.into_iter().map(|u| (u.email, u.name)).collect(), + "Budget — rapport hebdomadaire".to_string(), + report, + ) + } + Err(err) => { + error!("Error preparing weekly report from template: {:?}", err); + false + } + } +} + +async fn get_weekly_report( + pool: &SqlitePool, + templates: &Tera, +) -> Result { + let users = db::users::list(pool).await; + let incomes_from = db::incomes::defined_for_all(pool).await; + let user_incomes = match incomes_from { + Some(from) => db::incomes::cumulative(pool, from).await, + None => HashMap::new(), + }; + let user_payments = db::payments::repartition(pool).await; + let exceeding_payers = + payer::exceeding(&users, &user_incomes, &user_payments); + + let last_week_payments = db::payments::last_week(pool).await; + let last_week_incomes = db::incomes::last_week(pool).await; + + let mut context = Context::new(); + context.insert("exceeding_payers", &exceeding_payers); + context.insert("payments", &last_week_payments); + context.insert("incomes", &last_week_incomes); + + templates.render("report/report.j2", &context) +} diff --git a/src/mail.rs b/src/mail.rs new file mode 100644 index 0000000..d86cff3 --- /dev/null +++ b/src/mail.rs @@ -0,0 +1,59 @@ +use lettre::sendmail::SendmailTransport; +use lettre::{SendableEmail, Transport}; +use lettre_email::Email; + +use crate::model::config::Config; + +static FROM: &str = "contact@guyonvarch.me"; + +pub fn send( + config: &Config, + to: Vec<(String, String)>, + subject: String, + message: String, +) -> bool { + match prepare_email(to.clone(), subject.clone(), message.clone()) { + Ok(email) => { + if config.mock_mails { + let formatted_to = to + .into_iter() + .map(|t| t.0) + .collect::>() + .join(", "); + info!( + "MOCK MAIL\nto: {}\nsubject: {}\n\n{}", + formatted_to, subject, message + ); + true + } else { + let mut sender = + SendmailTransport::new_with_command(&config.sendmail_path); + match sender.send(email) { + Ok(_) => true, + Err(err) => { + error!("Error sending email: {:?}", err); + false + } + } + } + } + Err(err) => { + error!("Error preparing email: {:?}", err); + false + } + } +} + +fn prepare_email( + to: Vec<(String, String)>, + subject: String, + message: String, +) -> Result { + let mut email = Email::builder().from(FROM).subject(subject).text(message); + + for (address, name) in to.iter() { + email = email.to((address, name)); + } + + email.build().map(|e| e.into()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..502f608 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,88 @@ +use hyper::service::{make_service_fn, service_fn}; +use hyper::Server; +use sqlx::sqlite::SqlitePool; +use std::convert::Infallible; +use std::net::SocketAddr; +use structopt::StructOpt; + +#[macro_use] +extern crate log; + +mod assets; +mod controller; +mod db; +mod jobs; +mod mail; +mod model; +mod payer; +mod queries; +mod routes; +mod templates; +mod utils; +mod validation; + +use model::config::Config; + +#[derive(StructOpt)] +#[structopt()] +struct Opt { + #[structopt(short, long, default_value = "127.0.0.1:3000")] + address: SocketAddr, + + #[structopt(short, long, default_value = "config.json")] + config: String, + + #[structopt(short, long, default_value = "database.db")] + database: String, +} + +#[tokio::main] +async fn main() { + env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or("warn"), + ) + .init(); + + let opt = Opt::from_args(); + + let config_str = std::fs::read_to_string(&opt.config) + .expect(&format!("Missing {}", opt.config)); + let config: Config = serde_json::from_str(&config_str).unwrap(); + + let pool = SqlitePool::connect(&format!("sqlite:{}", opt.database)) + .await + .unwrap(); + + let assets = assets::get(); + + let templates = templates::get(); + + tokio::spawn(jobs::jobs::start( + config.clone(), + pool.clone(), + templates.clone(), + )); + + let make_svc = make_service_fn(|_conn| { + let config = config.clone(); + let pool = pool.clone(); + let assets = assets.clone(); + let templates = templates.clone(); + async move { + Ok::<_, Infallible>(service_fn(move |req| { + routes::routes( + config.clone(), + pool.clone(), + assets.clone(), + templates.clone(), + req, + ) + })) + } + }); + + info!("Starting server at {}", opt.address); + if let Err(e) = Server::bind(&opt.address).serve(make_svc).await { + error!("server error: {}", e); + } +} diff --git a/src/model/action.rs b/src/model/action.rs new file mode 100644 index 0000000..a77543a --- /dev/null +++ b/src/model/action.rs @@ -0,0 +1 @@ +use serde::Serialize; diff --git a/src/model/category.rs b/src/model/category.rs new file mode 100644 index 0000000..de08dea --- /dev/null +++ b/src/model/category.rs @@ -0,0 +1,20 @@ +use serde::Serialize; + +#[derive(sqlx::FromRow, Serialize)] +pub struct Category { + pub id: i64, + pub name: String, + pub color: String, +} + +#[derive(Debug)] +pub struct Create { + pub name: String, + pub color: String, +} + +#[derive(Debug)] +pub struct Update { + pub name: String, + pub color: String, +} diff --git a/src/model/config.rs b/src/model/config.rs new file mode 100644 index 0000000..8d304e5 --- /dev/null +++ b/src/model/config.rs @@ -0,0 +1,8 @@ +use serde::Deserialize; + +#[derive(Clone, Deserialize)] +pub struct Config { + pub secure_cookies: bool, + pub mock_mails: bool, + pub sendmail_path: String, +} diff --git a/src/model/frequency.rs b/src/model/frequency.rs new file mode 100644 index 0000000..bb83e27 --- /dev/null +++ b/src/model/frequency.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; +use std::{fmt, str}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, sqlx::Type)] +pub enum Frequency { + Punctual, + Monthly, +} + +impl fmt::Display for Frequency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Frequency::Punctual => write!(f, "Punctual"), + Frequency::Monthly => write!(f, "Monthly"), + } + } +} + +pub struct ParseFrequencyError; + +impl str::FromStr for Frequency { + type Err = ParseFrequencyError; + + fn from_str(s: &str) -> Result { + match s { + "Punctual" => Ok(Frequency::Punctual), + "Monthly" => Ok(Frequency::Monthly), + _ => Err(ParseFrequencyError {}), + } + } +} diff --git a/src/model/income.rs b/src/model/income.rs new file mode 100644 index 0000000..7bc888f --- /dev/null +++ b/src/model/income.rs @@ -0,0 +1,40 @@ +use serde::Serialize; + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct Stat { + pub date: String, + pub amount: i64, +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct Table { + pub id: i64, + pub date: String, + pub user: String, + pub amount: i64, +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct Form { + pub id: i64, + pub amount: i64, + pub user_id: i64, + pub month: i64, + pub year: i64, +} + +#[derive(Debug)] +pub struct Create { + pub user_id: i64, + pub amount: i64, + pub month: u32, + pub year: i32, +} + +#[derive(Debug)] +pub struct Update { + pub user_id: i64, + pub amount: i64, + pub month: u32, + pub year: i32, +} diff --git a/src/model/job.rs b/src/model/job.rs new file mode 100644 index 0000000..74151ae --- /dev/null +++ b/src/model/job.rs @@ -0,0 +1,5 @@ +#[derive(Debug, sqlx::Type)] +pub enum Job { + MonthlyPayment, + WeeklyReport, +} diff --git a/src/model/login.rs b/src/model/login.rs new file mode 100644 index 0000000..c7a10ba --- /dev/null +++ b/src/model/login.rs @@ -0,0 +1,5 @@ +#[derive(Debug)] +pub struct Login { + pub email: String, + pub password: String, +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..fb07721 --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,9 @@ +pub mod category; +pub mod config; +pub mod frequency; +pub mod income; +pub mod job; +pub mod login; +pub mod payment; +pub mod report; +pub mod user; diff --git a/src/model/payment.rs b/src/model/payment.rs new file mode 100644 index 0000000..5ce6bb9 --- /dev/null +++ b/src/model/payment.rs @@ -0,0 +1,53 @@ +use chrono::NaiveDate; +use serde::Serialize; + +use crate::model::frequency::Frequency; + +#[derive(Debug, sqlx::FromRow, Serialize)] +pub struct Table { + pub id: i64, + pub name: String, + pub cost: i64, + pub user: String, + pub category_name: String, + pub category_color: String, + pub date: String, + pub frequency: Frequency, +} + +#[derive(Debug, sqlx::FromRow, Serialize)] +pub struct Form { + pub id: i64, + pub name: String, + pub cost: i64, + pub user_id: i64, + pub category_id: i64, + pub date: String, + pub frequency: Frequency, +} + +#[derive(Debug, sqlx::FromRow, Serialize)] +pub struct Stat { + pub start_date: String, + pub cost: i64, + pub category_id: i64, +} + +#[derive(Debug)] +pub struct Create { + pub name: String, + pub cost: i64, + pub user_id: i64, + pub category_id: i64, + pub date: NaiveDate, + pub frequency: Frequency, +} + +#[derive(Debug)] +pub struct Update { + pub name: String, + pub cost: i64, + pub user_id: i64, + pub category_id: i64, + pub date: NaiveDate, +} diff --git a/src/model/report.rs b/src/model/report.rs new file mode 100644 index 0000000..4858402 --- /dev/null +++ b/src/model/report.rs @@ -0,0 +1,16 @@ +use serde::Serialize; + +#[derive(Debug, sqlx::FromRow, Serialize)] +pub struct Report { + pub date: String, + pub name: String, + pub amount: i64, + pub action: Action, +} + +#[derive(Debug, PartialEq, Serialize, sqlx::Type)] +pub enum Action { + Created, + Updated, + Deleted, +} diff --git a/src/model/user.rs b/src/model/user.rs new file mode 100644 index 0000000..e8a61bf --- /dev/null +++ b/src/model/user.rs @@ -0,0 +1,8 @@ +use serde::Serialize; + +#[derive(Debug, sqlx::FromRow, Clone, Serialize)] +pub struct User { + pub id: i64, + pub name: String, + pub email: String, +} diff --git a/src/payer.rs b/src/payer.rs new file mode 100644 index 0000000..48cee52 --- /dev/null +++ b/src/payer.rs @@ -0,0 +1,38 @@ +use std::collections::HashMap; + +use crate::model::user::User; + +pub fn exceeding( + users: &Vec, + user_incomes: &HashMap, + user_payments: &HashMap, +) -> Vec<(String, i64)> { + let ratios = users.into_iter().map(|u| { + let income = *user_incomes.get(&u.id).unwrap_or(&0); + if income == 0 { + (u.name.clone(), 0, 0.0) + } else { + let payments = *user_payments.get(&u.id).unwrap_or(&0); + let ratio = payments as f64 / income as f64; + (u.name.clone(), income, ratio) + } + }); + let min_ratio = ratios + .clone() + .map(|r| r.2) + .min_by(|r1, r2| { + ((r1 * 100_000_000.0).round() as i64) + .cmp(&((r2 * 100_000_000.0).round() as i64)) + }) + .unwrap_or(0.0); + ratios + .filter_map(|r| { + let exceeding = ((r.2 - min_ratio) * r.1 as f64).round() as i64; + if exceeding == 0 { + None + } else { + Some((r.0, exceeding)) + } + }) + .collect() +} diff --git a/src/queries.rs b/src/queries.rs new file mode 100644 index 0000000..a7ba28c --- /dev/null +++ b/src/queries.rs @@ -0,0 +1,62 @@ +use crate::model::frequency::Frequency; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone)] +pub struct Payments { + pub page: Option, + pub search: Option, + pub frequency: Option, + pub highlight: Option, +} + +pub fn payments_url(q: Payments) -> String { + let mut params = Vec::new(); + + match q.page { + None | Some(1) => (), + Some(p) => params.push(format!("page={}", p)), + }; + + match q.search { + Some(s) => { + if !s.is_empty() { + params.push(format!("search={}", s)); + } + } + _ => (), + }; + + match q.frequency { + Some(Frequency::Monthly) => { + params.push("frequency=Monthly".to_string()) + } + _ => (), + }; + + match q.highlight { + Some(id) => params.push(format!("highlight={}", id)), + _ => (), + }; + + if params.is_empty() { + "".to_string() + } else { + format!("?{}", params.join("&")) + } +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct Incomes { + pub page: Option, + pub highlight: Option, +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct Categories { + pub highlight: Option, +} + +#[derive(Deserialize, Serialize)] +pub struct PaymentCategory { + pub payment_name: String, +} diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..3d76ab1 --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,219 @@ +use hyper::{Body, Method, Request, Response}; +use serde::Deserialize; +use serde_urlencoded; +use sqlx::sqlite::SqlitePool; +use std::collections::HashMap; +use std::convert::Infallible; +use tera::Tera; +use url::form_urlencoded; + +use crate::controller; +use crate::controller::wallet::Wallet; +use crate::db; +use crate::model::config::Config; +use crate::model::user::User; + +pub async fn routes( + config: Config, + pool: SqlitePool, + assets: HashMap, + templates: Tera, + request: Request, +) -> Result, Infallible> { + let method = request.method(); + let uri = request.uri(); + let path = &uri.path().split('/').collect::>()[1..]; + + let response = match (method, path) { + (&Method::GET, ["login"]) => { + controller::login::page(&assets, &templates, None).await + } + (&Method::POST, ["login"]) => { + controller::login::login( + config, + &assets, + &templates, + body_form(request).await, + pool, + ) + .await + } + (&Method::GET, ["assets", _, file]) => { + controller::utils::file(&format!("assets/{}", file)).await + } + _ => match connected_user(&pool, &request).await { + Some(user) => { + let wallet = Wallet { + pool, + assets, + templates, + user, + }; + authenticated_routes(config, wallet, request).await + } + None => controller::utils::redirect("/login"), + }, + }; + + Ok(response) +} + +async fn connected_user( + pool: &SqlitePool, + request: &Request, +) -> Option { + let cookie = request.headers().get("COOKIE")?.to_str().ok()?; + let mut xs = cookie.split('='); + xs.next(); + let login_token = xs.next()?; + db::users::get_by_login_token(&pool, login_token.to_string()).await +} + +async fn authenticated_routes( + config: Config, + wallet: Wallet, + request: Request, +) -> Response { + let method = request.method(); + let uri = request.uri(); + let path = &uri.path().split('/').collect::>()[1..]; + let query = uri.query(); + + match (method, path) { + (&Method::GET, [""]) => { + controller::payments::table(&wallet, parse_query(query)).await + } + (&Method::GET, ["payment"]) => { + controller::payments::create_form(&wallet, parse_query(query)).await + } + (&Method::POST, ["payment", "create"]) => { + controller::payments::create( + &wallet, + parse_query(query), + body_form(request).await, + ) + .await + } + (&Method::GET, ["payment", "category"]) => { + controller::payments::search_category(&wallet, parse_query(query)) + .await + } + (&Method::GET, ["payment", id]) => { + controller::payments::update_form( + parse_id(id), + &wallet, + parse_query(query), + ) + .await + } + (&Method::POST, ["payment", id, "update"]) => { + controller::payments::update( + parse_id(id), + &wallet, + parse_query(query), + body_form(request).await, + ) + .await + } + (&Method::POST, ["payment", id, "delete"]) => { + controller::payments::delete( + parse_id(id), + &wallet, + parse_query(query), + ) + .await + } + (&Method::GET, ["incomes"]) => { + controller::incomes::table(&wallet, parse_query(query)).await + } + (&Method::GET, ["income"]) => { + controller::incomes::create_form(&wallet, parse_query(query)).await + } + (&Method::POST, ["income", "create"]) => { + controller::incomes::create( + &wallet, + parse_query(query), + body_form(request).await, + ) + .await + } + (&Method::GET, ["income", id]) => { + controller::incomes::update_form( + parse_id(id), + &wallet, + parse_query(query), + ) + .await + } + (&Method::POST, ["income", id, "update"]) => { + controller::incomes::update( + parse_id(id), + &wallet, + parse_query(query), + body_form(request).await, + ) + .await + } + (&Method::POST, ["income", id, "delete"]) => { + controller::incomes::delete( + parse_id(id), + &wallet, + parse_query(query), + ) + .await + } + (&Method::GET, ["categories"]) => { + controller::categories::table(&wallet, parse_query(query)).await + } + (&Method::GET, ["category"]) => { + controller::categories::create_form(&wallet).await + } + (&Method::POST, ["category", "create"]) => { + controller::categories::create(&wallet, body_form(request).await) + .await + } + (&Method::GET, ["category", id]) => { + controller::categories::update_form(parse_id(id), &wallet).await + } + (&Method::POST, ["category", id, "update"]) => { + controller::categories::update( + parse_id(id), + &wallet, + body_form(request).await, + ) + .await + } + (&Method::POST, ["category", id, "delete"]) => { + controller::categories::delete(parse_id(id), &wallet).await + } + (&Method::GET, ["balance"]) => controller::balance::get(&wallet).await, + (&Method::GET, ["statistics"]) => { + controller::statistics::get(&wallet).await + } + (&Method::POST, ["logout"]) => { + controller::login::logout(config, &wallet).await + } + _ => controller::error::error( + &wallet, + "Page introuvable", + "La page que recherchez n’existe pas.", + ), + } +} + +fn parse_query<'a, T: Deserialize<'a>>(query: Option<&'a str>) -> T { + serde_urlencoded::from_str(query.unwrap_or("")).unwrap() +} + +async fn body_form(request: Request) -> HashMap { + match hyper::body::to_bytes(request).await { + Ok(bytes) => form_urlencoded::parse(bytes.as_ref()) + .into_owned() + .collect::>(), + Err(_) => HashMap::new(), + } +} + +fn parse_id(str: &str) -> i64 { + str.parse::().unwrap() +} diff --git a/src/templates.rs b/src/templates.rs new file mode 100644 index 0000000..7e3753a --- /dev/null +++ b/src/templates.rs @@ -0,0 +1,97 @@ +use serde::Serialize; +use serde_json::json; +use serde_json::value::Value; +use std::collections::HashMap; +use tera::Tera; +use tera::{Error, Result}; + +use crate::queries; + +#[derive(Debug, Serialize)] +pub enum Header { + Payments, + Categories, + Incomes, + Balance, + Statistics, +} + +pub fn get() -> Tera { + let mut tera = match Tera::new("templates/**/*") { + Ok(t) => t, + Err(e) => { + error!("Parsing error(s): {}", e); + ::std::process::exit(1); + } + }; + tera.register_function("payments_params", payments_params); + tera.register_filter("numeric", numeric); + tera.register_filter("euros", euros); + tera +} + +fn payments_params(args: &HashMap) -> Result { + let q = json!({ + "page": args.get("page"), + "search": args.get("search"), + "frequency": args.get("frequency"), + "highlight": args.get("highlight"), + }); + + match serde_json::from_value(q) { + Ok(q) => Ok(json!(queries::payments_url(q))), + Err(msg) => Err(Error::msg(msg)), + } +} + +fn euros(value: &Value, _: &HashMap) -> Result { + match value { + Value::Number(n) => { + if let Some(n) = n.as_i64() { + let str = rgrouped(n.abs().to_string(), 3).join(" "); + let sign = if n < 0 { "-" } else { "" }; + Ok(json!(format!("{}{} €", sign, str))) + } else if let Some(n) = n.as_f64() { + Ok(json!(format!("{} €", n.to_string()))) + } else { + Err(Error::msg("Error parsing number")) + } + } + _ => Err(Error::msg(format!("{:?} should be a number", value))), + } +} + +fn numeric(value: &Value, _: &HashMap) -> Result { + match value { + Value::Number(n) => { + if let Some(n) = n.as_i64() { + let str = rgrouped(n.abs().to_string(), 3).join(" "); + let sign = if n < 0 { "-" } else { "" }; + Ok(json!(format!("{}{}", sign, str))) + } else if let Some(n) = n.as_f64() { + Ok(json!(format!("{}", n.to_string()))) + } else { + Err(Error::msg("Error parsing number")) + } + } + _ => Err(Error::msg(format!("{:?} should be a number", value))), + } +} + +fn rgrouped(str: String, n: usize) -> Vec { + let mut str = str.clone(); + let mut l = str.len(); + let mut res = vec![]; + while l > n { + let str2 = str.clone(); + let (start, end) = str2.split_at(l - n); + l -= n; + str = start.to_string(); + res.push(end.to_string()); + } + if !str.is_empty() { + res.push(str); + } + res.reverse(); + res +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..481c63a --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod text; diff --git a/src/utils/text.rs b/src/utils/text.rs new file mode 100644 index 0000000..c07ccee --- /dev/null +++ b/src/utils/text.rs @@ -0,0 +1,19 @@ +pub fn format_search(str: &String) -> String { + unaccent(&str.to_lowercase()) +} + +pub fn unaccent(str: &String) -> String { + str.chars().map(unaccent_char).collect() +} + +pub fn unaccent_char(c: char) -> char { + match c { + 'à' | 'â' => 'a', + 'ç' => 'c', + 'è' | 'é' | 'ê' | 'ë' => 'e', + 'î' | 'ï' => 'i', + 'ô' => 'o', + 'ù' | 'û' | 'ü' => 'u', + _ => c, + } +} diff --git a/src/validation/category.rs b/src/validation/category.rs new file mode 100644 index 0000000..7b1b5d5 --- /dev/null +++ b/src/validation/category.rs @@ -0,0 +1,18 @@ +use std::collections::HashMap; + +use crate::model::category::{Create, Update}; +use crate::validation::utils::*; + +pub fn create(form: &HashMap) -> Option { + Some(Create { + name: non_empty(form, "name")?, + color: color(form, "color")?, + }) +} + +pub fn update(form: &HashMap) -> Option { + Some(Update { + name: non_empty(form, "name")?, + color: color(form, "color")?, + }) +} diff --git a/src/validation/income.rs b/src/validation/income.rs new file mode 100644 index 0000000..972e42a --- /dev/null +++ b/src/validation/income.rs @@ -0,0 +1,22 @@ +use std::collections::HashMap; + +use crate::model::income::{Create, Update}; +use crate::validation::utils::*; + +pub fn create(form: &HashMap) -> Option { + Some(Create { + user_id: parse::(form, "user_id")?, + amount: parse::(form, "amount")?, + month: parse::(form, "month")?, + year: parse::(form, "year")?, + }) +} + +pub fn update(form: &HashMap) -> Option { + Some(Update { + user_id: parse::(form, "user_id")?, + amount: parse::(form, "amount")?, + month: parse::(form, "month")?, + year: parse::(form, "year")?, + }) +} diff --git a/src/validation/login.rs b/src/validation/login.rs new file mode 100644 index 0000000..e40bb23 --- /dev/null +++ b/src/validation/login.rs @@ -0,0 +1,11 @@ +use std::collections::HashMap; + +use crate::model::login::Login; +use crate::validation::utils::*; + +pub fn login(form: &HashMap) -> Option { + Some(Login { + email: non_empty(form, "email")?, + password: non_empty(form, "password")?, + }) +} diff --git a/src/validation/mod.rs b/src/validation/mod.rs new file mode 100644 index 0000000..181abc7 --- /dev/null +++ b/src/validation/mod.rs @@ -0,0 +1,5 @@ +pub mod category; +pub mod income; +pub mod login; +pub mod payment; +pub mod utils; diff --git a/src/validation/payment.rs b/src/validation/payment.rs new file mode 100644 index 0000000..36aa852 --- /dev/null +++ b/src/validation/payment.rs @@ -0,0 +1,25 @@ +use std::collections::HashMap; + +use crate::model::payment::{Create, Update}; +use crate::validation::utils::*; + +pub fn create(form: &HashMap) -> Option { + Some(Create { + name: non_empty(form, "name")?, + cost: parse::(form, "cost")?, + user_id: parse::(form, "user_id")?, + category_id: parse::(form, "category_id")?, + date: date(form, "date")?, + frequency: frequency(form, "frequency")?, + }) +} + +pub fn update(form: &HashMap) -> Option { + Some(Update { + name: non_empty(form, "name")?, + cost: parse::(form, "cost")?, + user_id: parse::(form, "user_id")?, + category_id: parse::(form, "category_id")?, + date: date(form, "date")?, + }) +} diff --git a/src/validation/utils.rs b/src/validation/utils.rs new file mode 100644 index 0000000..4bff40a --- /dev/null +++ b/src/validation/utils.rs @@ -0,0 +1,54 @@ +use chrono::NaiveDate; +use std::collections::HashMap; +use std::str::FromStr; + +use crate::model::frequency::Frequency; + +pub fn non_empty( + form: &HashMap, + field: &str, +) -> Option { + let s = form.get(field)?.trim(); + if s.is_empty() { + None + } else { + Some(s.to_string()) + } +} + +pub fn parse( + form: &HashMap, + field: &str, +) -> Option { + let s = form.get(field)?; + s.parse::().ok() +} + +pub fn date(form: &HashMap, field: &str) -> Option { + let s = form.get(field)?; + NaiveDate::parse_from_str(s, "%Y-%m-%d").ok() +} + +pub fn frequency( + form: &HashMap, + field: &str, +) -> Option { + let s = form.get(field)?; + Frequency::from_str(s).ok() +} + +pub fn color(form: &HashMap, field: &str) -> Option { + let s = form.get(field)?; + if s.len() == 7 + && &s[0..1] == "#" + && s[1..] + .to_string() + .into_bytes() + .into_iter() + .all(|c| c.is_ascii_hexdigit()) + { + Some(s.to_string()) + } else { + None + } +} -- cgit v1.2.3