From ac1464ead561667f26c0052591e017a210bc01d9 Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 11:26:29 -0300 Subject: [PATCH 01/18] migration: stopping actual deletion of data from db, adding unique uuid constraint, making user_id not null and removing default from date --- ...28224920_complement-expenses-table.down.sql | 10 ++++++++++ ...0928224920_complement-expenses-table.up.sql | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 migrations/20240928224920_complement-expenses-table.down.sql create mode 100644 migrations/20240928224920_complement-expenses-table.up.sql diff --git a/migrations/20240928224920_complement-expenses-table.down.sql b/migrations/20240928224920_complement-expenses-table.down.sql new file mode 100644 index 0000000..2ed0c1a --- /dev/null +++ b/migrations/20240928224920_complement-expenses-table.down.sql @@ -0,0 +1,10 @@ +ALTER TABLE expenses + DROP CONSTRAINT unique_uuid; + +ALTER TABLE expenses + DROP COLUMN deleted_at, + DROP COLUMN created_at; + +ALTER TABLE expenses + ALTER COLUMN user_id DROP NOT NULL, + ALTER COLUMN date SET DEFAULT CURRENT_DATE; diff --git a/migrations/20240928224920_complement-expenses-table.up.sql b/migrations/20240928224920_complement-expenses-table.up.sql new file mode 100644 index 0000000..99705b5 --- /dev/null +++ b/migrations/20240928224920_complement-expenses-table.up.sql @@ -0,0 +1,18 @@ +ALTER TABLE expenses + ALTER COLUMN date DROP DEFAULT, + ALTER COLUMN user_id SET NOT NULL; + +ALTER TABLE expenses + ADD COLUMN created_at timestamptz, + ADD COLUMN deleted_at timestamptz; + +ALTER TABLE expenses + ADD CONSTRAINT unique_uuid UNIQUE (uuid); + +UPDATE + expenses +SET + created_at = now(); + +ALTER TABLE expenses + ALTER COLUMN created_at SET NOT NULL; From a8c4dee88e1dc311a9517598e52b509c54ccf13d Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 11:28:27 -0300 Subject: [PATCH 02/18] feat: structuring codebase into routers, features and queries refa: changed expenses data api to use the new structure --- src/data/router/expenses.rs | 133 +++++++++++++++++----- src/data/service/expenses.rs | 207 ----------------------------------- src/data/service/mod.rs | 1 - src/features/expenses.rs | 100 +++++++++++++++++ src/features/mod.rs | 1 + src/main.rs | 1 + src/queries/expenses.rs | 165 ++++++++++++++++++++++++++++ src/queries/mod.rs | 1 + 8 files changed, 374 insertions(+), 235 deletions(-) delete mode 100644 src/data/service/expenses.rs create mode 100644 src/features/expenses.rs create mode 100644 src/queries/expenses.rs create mode 100644 src/queries/mod.rs diff --git a/src/data/router/expenses.rs b/src/data/router/expenses.rs index 74e6d62..eaaeb17 100644 --- a/src/data/router/expenses.rs +++ b/src/data/router/expenses.rs @@ -1,6 +1,8 @@ use askama_axum::IntoResponse; use axum::{ extract::{Path, Query, State}, + http::StatusCode, + response::Response, routing::get, Json, Router, }; @@ -9,68 +11,145 @@ use uuid::Uuid; use crate::{ auth::AuthSession, - schema::{GetExpense, UpdateExpense, UpdateExpenseApi}, + schema::{CreateExpense, GetExpense, UpdateExpenseApi}, AppState, }; pub fn router() -> Router> { Router::new() - .route("/api/expenses", get(get_expenses).post(insert_expense)) - .route( - "/api/expenses/:uuid", - get(get_expense).put(update_expense).delete(delete_expense), - ) + .route("/api/expenses", get(list).post(create)) + .route("/api/expenses/:uuid", get(find).put(update).delete(delete)) } -async fn get_expenses( +async fn list( auth_session: AuthSession, State(shared_state): State>, Query(get_expenses_input): Query, -) -> impl IntoResponse { - crate::data::service::expenses::get_expenses( - auth_session, - &shared_state.pool, - get_expenses_input, +) -> Result>, Response> { + let user_id = if let Some(user) = auth_session.user { + tracing::info!("User logged in"); + user.id + } else { + tracing::error!("User not logged in"); + return Err(StatusCode::UNAUTHORIZED.into_response()); + }; + + match crate::features::expenses::list_in_period( + user_id, + shared_state.pool.clone(), + get_expenses_input.from, + get_expenses_input.to, ) .await + { + Ok(expenses) => Ok(Json(expenses)), + Err(e) => { + tracing::error!(user_id, "Error inserting expense: {}", e); + Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()) + } + } } -async fn insert_expense( +async fn create( auth_session: AuthSession, State(shared_state): State>, - Json(create_expense): Json, -) -> impl IntoResponse { - crate::data::service::expenses::insert_expense(auth_session, &shared_state.pool, create_expense) + Json(create_expense): Json, +) -> Result { + let user_id = if let Some(user) = auth_session.user { + tracing::info!("User logged in"); + user.id + } else { + tracing::error!("User not logged in"); + return Err(StatusCode::UNAUTHORIZED.into_response()); + }; + + match crate::features::expenses::create(user_id, shared_state.pool.clone(), create_expense) .await + { + Ok(()) => Ok(StatusCode::OK), + Err(e) => { + tracing::error!(user_id, "Error inserting expense: {}", e); + Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()) + } + } } -async fn get_expense( +async fn find( auth_session: AuthSession, Path(uuid): Path, State(shared_state): State>, -) -> impl IntoResponse { - crate::data::service::expenses::get_expense(auth_session, &shared_state.pool, uuid).await +) -> Result, Response> { + let user_id = if let Some(user) = auth_session.user { + tracing::info!("User logged in"); + user.id + } else { + tracing::error!("User not logged in"); + return Err(StatusCode::UNAUTHORIZED.into_response()); + }; + + match crate::features::expenses::find_active_for_user(user_id, shared_state.pool.clone(), uuid) + .await + { + Ok(expense) => Ok(Json(expense)), + Err(e) => { + tracing::error!(user_id, "Error inserting expense: {}", e); + Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()) + } + } } -async fn update_expense( +async fn update( auth_session: AuthSession, Path(uuid): Path, State(shared_state): State>, Json(update_expense): Json, -) -> impl IntoResponse { - crate::data::service::expenses::update_expense( - auth_session, - &shared_state.pool, +) -> Result { + let user_id = if let Some(user) = auth_session.user { + tracing::info!("User logged in"); + user.id + } else { + tracing::error!("User not logged in"); + return Err(StatusCode::UNAUTHORIZED.into_response()); + }; + + match crate::features::expenses::update( + user_id, + shared_state.pool.clone(), uuid, update_expense, ) .await + { + Ok(()) => Ok(StatusCode::OK), + Err(e) => { + tracing::error!(user_id, "Error inserting expense: {}", e); + Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()) + } + } } -async fn delete_expense( +async fn delete( auth_session: AuthSession, Path(uuid): Path, State(shared_state): State>, -) -> impl IntoResponse { - crate::data::service::expenses::delete_expense(auth_session, &shared_state.pool, uuid).await +) -> Result { + let user_id = if let Some(user) = auth_session.user { + tracing::info!("User logged in"); + user.id + } else { + tracing::error!("User not logged in"); + return Err(StatusCode::UNAUTHORIZED.into_response()); + }; + + let outcome = crate::features::expenses::delete(user_id, shared_state.pool.clone(), uuid) + .await + .map_err(|e| { + tracing::error!(user_id, "Error inserting expense: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + })?; + + match outcome { + crate::features::expenses::DeleteOutcome::Success => Ok(StatusCode::OK), + crate::features::expenses::DeleteOutcome::NotFound => Ok(StatusCode::NOT_FOUND), + } } diff --git a/src/data/service/expenses.rs b/src/data/service/expenses.rs deleted file mode 100644 index 3e3764c..0000000 --- a/src/data/service/expenses.rs +++ /dev/null @@ -1,207 +0,0 @@ -use askama_axum::IntoResponse; -use axum::{http::StatusCode, Json}; -use sqlx::{Pool, Postgres}; -use uuid::Uuid; - -use crate::{ - auth::AuthSession, - schema::{Expense, ExpenseCategory, GetExpense, UpdateExpense, UpdateExpenseApi}, - util::{get_first_day_from_month_or_none, get_last_day_from_month_or_none}, -}; - -pub async fn get_expenses( - auth_session: AuthSession, - db_pool: &Pool, - get_expense_input: GetExpense, -) -> Result { - let user_id = if let Some(user) = auth_session.user { - tracing::info!("User logged in"); - user.id - } else { - tracing::error!("User not logged in"); - return Err((StatusCode::UNAUTHORIZED, String::new())); - }; - - let first_day_of_month = match get_first_day_from_month_or_none(get_expense_input.month.clone()) - { - Ok(date) => date, - Err(e) => { - tracing::error!("Error getting first day of month: {}", e); - return Err((StatusCode::INTERNAL_SERVER_ERROR, String::new())); - } - }; - let last_day_of_month = match get_last_day_from_month_or_none(get_expense_input.month) { - Ok(date) => date, - Err(e) => { - tracing::error!("Error getting last day of month: {}", e); - return Err((StatusCode::INTERNAL_SERVER_ERROR, String::new())); - } - }; - match sqlx::query_as!( - Expense, - r#"SELECT id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid - FROM expenses - WHERE ((date >= $1) OR ($1 IS NULL)) - AND ((date <= $2) OR ($2 IS NULL)) - AND user_id = $3 - ORDER BY date ASC"#, - first_day_of_month, - last_day_of_month, - user_id - ) - .fetch_all(db_pool) - .await - { - Ok(expenses) => Ok((StatusCode::CREATED, Json(expenses))), - Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), - } -} - -pub async fn insert_expense( - auth_session: AuthSession, - db_pool: &Pool, - create_expense: UpdateExpense, -) -> Result { - let user_id = if let Some(user) = auth_session.user { - tracing::info!("User logged in"); - user.id - } else { - tracing::error!("User not logged in"); - return Err((StatusCode::UNAUTHORIZED, String::new())); - }; - - match sqlx::query_as!( - Expense, - r#" - INSERT INTO expenses (description, price, category, is_essential, date, uuid, user_id) - VALUES ($1, $2, $3 :: expense_category, $4, $5, $6, $7) - RETURNING id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid - "#, - create_expense.description, - create_expense.price, - create_expense.category as Option, - create_expense.is_essential, - create_expense.date, - Uuid::new_v4(), - user_id - ) - .fetch_one(db_pool) - .await { - Ok(expense) => Ok((StatusCode::CREATED, Json(expense))), - Err(e) => { - tracing::error!("Error inserting expense: {}", e); - Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) - }, - } -} - -pub async fn get_expense( - auth_session: AuthSession, - db_pool: &Pool, - uuid: Uuid, -) -> Result { - let user_id = if let Some(user) = auth_session.user { - tracing::info!("User logged in"); - user.id - } else { - tracing::error!("User not logged in"); - return Err((StatusCode::UNAUTHORIZED, String::new())); - }; - - let expense = sqlx::query_as!( - Expense, - r#"SELECT id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid - FROM expenses WHERE uuid = $1 AND user_id = $2"#, - uuid, - user_id - ) - .fetch_one(db_pool) - .await - .unwrap(); - - Ok((StatusCode::FOUND, Json(expense))) -} - -pub async fn update_expense( - auth_session: AuthSession, - db_pool: &Pool, - uuid: Uuid, - update_expense: UpdateExpenseApi, -) -> Result { - let user_id = if let Some(user) = auth_session.user { - tracing::info!("User logged in"); - user.id - } else { - tracing::error!("User not logged in"); - return Err((StatusCode::UNAUTHORIZED, String::new())); - }; - - match sqlx::query_as!( - Expense, - r#" - UPDATE expenses SET - description = COALESCE($1, description), - price = COALESCE($2, price), - category = COALESCE($3 :: expense_category, category), - is_essential = COALESCE($4, is_essential), - date = COALESCE($5, date) - WHERE uuid = $6 AND user_id = $7 - RETURNING id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid - "#, - update_expense.description, - update_expense.price, - update_expense.category as Option, - update_expense.is_essential, - update_expense.date, - uuid, - user_id - ) - .fetch_one(db_pool) - .await { - Ok(expense) => Ok((StatusCode::OK, Json(expense))), - Err(e) => { - tracing::error!("Error updating expense: {}", e); - Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) - }, - } -} - -pub async fn delete_expense( - auth_session: AuthSession, - db_pool: &Pool, - uuid: Uuid, -) -> Result { - let user_id = if let Some(user) = auth_session.user { - tracing::info!("User logged in"); - user.id - } else { - tracing::error!("User not logged in"); - return Err((StatusCode::UNAUTHORIZED, String::new())); - }; - - // TODO: I should have a constraint of uuid - // NOTE: check that later - match sqlx::query!( - r#" - DELETE FROM expenses - WHERE uuid = $1 AND user_id = $2 - "#, - uuid, - user_id - ) - .execute(db_pool) - .await - { - Ok(q_res) => { - if q_res.rows_affected() == 1 { - Ok(StatusCode::NO_CONTENT) - } else { - Err((StatusCode::NOT_FOUND, String::new())) - } - } - Err(e) => { - tracing::error!("Error deleting expense: {}", e); - Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) - } - } -} diff --git a/src/data/service/mod.rs b/src/data/service/mod.rs index 32ec6f3..0e4a05d 100644 --- a/src/data/service/mod.rs +++ b/src/data/service/mod.rs @@ -1,2 +1 @@ pub mod auth; -pub mod expenses; diff --git a/src/features/expenses.rs b/src/features/expenses.rs new file mode 100644 index 0000000..59bdc8e --- /dev/null +++ b/src/features/expenses.rs @@ -0,0 +1,100 @@ +use chrono::NaiveDate; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; + +use crate::schema::{CreateExpense, UpdateExpenseApi}; + +pub async fn create( + user_id: i32, + db_pool: Pool, + create_expense: CreateExpense, +) -> Result<(), sqlx::Error> { + let params = crate::queries::expenses::CreateParams { + description: create_expense.description, + price: create_expense.price, + category: create_expense.category, + is_essential: create_expense.is_essential, + date: create_expense.date, + }; + crate::queries::expenses::create(&db_pool, user_id, params) + .await + .map(|c| { + if c.rows_affected() > 1 { + tracing::error!("i really need a macro that cancels the transaction"); + } + Ok(()) + })? +} + +pub async fn list_in_period( + user_id: i32, + db_pool: Pool, + from: Option, + to: Option, +) -> Result, sqlx::Error> { + crate::queries::expenses::list_for_user_in_period(&db_pool, user_id, from, to).await +} + +pub async fn find_active_for_user( + user_id: i32, + db_pool: Pool, + expense_uuid: Uuid, +) -> Result { + crate::queries::expenses::find_active_for_user(&db_pool, user_id, expense_uuid).await +} + +pub async fn update( + user_id: i32, + db_pool: Pool, + expense_uuid: Uuid, + update: UpdateExpenseApi, +) -> Result<(), sqlx::Error> { + let params = crate::queries::expenses::UpdateParams { + description: update.description, + price: update.price, + category: update.category, + is_essential: update.is_essential, + date: update.date, + }; + crate::queries::expenses::update(&db_pool, user_id, expense_uuid, params) + .await + .map(|c| { + if c.rows_affected() > 1 { + tracing::error!("i really need a macro that cancels the transaction"); + } + Ok(()) + })? +} + +pub enum DeleteOutcome { + Success, + NotFound, +} + +pub async fn delete( + user_id: i32, + db_pool: Pool, + expense_uuid: Uuid, +) -> anyhow::Result { + if let Some(exists) = + crate::queries::expenses::exists_active(&db_pool, user_id, expense_uuid).await? + { + if !exists { + tracing::warn!("entered via Some false"); + return Ok(DeleteOutcome::NotFound); + } + } else { + tracing::warn!("entered via None"); + return Ok(DeleteOutcome::NotFound); + } + + crate::queries::expenses::delete(&db_pool, user_id, expense_uuid, chrono::Utc::now()) + .await + .map(|c| { + if c.rows_affected() > 1 { + tracing::error!("i really need a macro that cancels the transaction"); + } + })?; + + Ok(DeleteOutcome::Success) +} diff --git a/src/features/mod.rs b/src/features/mod.rs index c0dc7bd..4b9bbab 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -1,2 +1,3 @@ +pub mod expenses; pub mod openfinance; pub mod totp; diff --git a/src/main.rs b/src/main.rs index 2ea56d0..64f117a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod data; mod data_structs; mod features; mod hypermedia; +mod queries; /// Module containing the database schemas and i/o schemas for hypermedia and data apis. mod schema; /// Module containing the askama html templates to be rendered. diff --git a/src/queries/expenses.rs b/src/queries/expenses.rs new file mode 100644 index 0000000..2daa7a1 --- /dev/null +++ b/src/queries/expenses.rs @@ -0,0 +1,165 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use serde::Serialize; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; + +use crate::schema::ExpenseCategory; + +pub struct CreateParams { + pub description: String, + pub price: f32, + pub category: ExpenseCategory, + pub is_essential: bool, + pub date: NaiveDate, +} + +pub async fn create( + db_pool: &Pool, + user_id: i32, + p: CreateParams, +) -> Result { + sqlx::query!( + r#" + INSERT INTO expenses (description, price, category, is_essential, date, uuid, user_id) + VALUES ($1, $2, $3 :: expense_category, $4, $5, $6, $7) + "#, + p.description, + p.price, + p.category as ExpenseCategory, + p.is_essential, + p.date, + Uuid::new_v4(), + user_id + ) + .execute(db_pool) + .await +} + +#[derive(Serialize)] +/// Expense is a struct with the fields of an expense. +pub struct Expense { + pub id: i32, + pub description: String, + pub price: f32, + pub category: ExpenseCategory, + pub is_essential: bool, + pub date: NaiveDate, + pub uuid: Uuid, +} + +pub async fn list_for_user_in_period( + db_pool: &Pool, + user_id: i32, + from: Option, + to: Option, +) -> Result, sqlx::Error> { + sqlx::query_as!( + Expense, + r#"SELECT id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid + FROM expenses + WHERE ((date >= $1) OR ($1 IS NULL)) + AND ((date <= $2) OR ($2 IS NULL)) + AND user_id = $3 + and deleted_at is null + ORDER BY date ASC"#, + from, + to, + user_id + ) + .fetch_all(db_pool) + .await +} + +pub async fn find_active_for_user( + db_pool: &Pool, + user_id: i32, + expense_uuid: Uuid, +) -> Result { + sqlx::query_as!( + Expense, + r#"SELECT id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid + FROM expenses + WHERE uuid = $1 AND user_id = $2 + and deleted_at is null + "#, + expense_uuid, + user_id + ) + .fetch_one(db_pool) + .await +} + +pub async fn exists_active( + db_pool: &Pool, + user_id: i32, + expense_uuid: Uuid, +) -> Result, sqlx::Error> { + let record = sqlx::query!( + r#" + select exists(select 1 from expenses where uuid = $1 AND user_id = $2) + "#, + expense_uuid, + user_id + ) + .fetch_one(db_pool) + .await?; + + Ok(record.exists) +} + +pub struct UpdateParams { + pub description: Option, + pub price: Option, + pub category: Option, + pub is_essential: Option, + pub date: Option, +} + +pub async fn update( + db_pool: &Pool, + user_id: i32, + expense_uuid: Uuid, + p: UpdateParams, +) -> Result { + sqlx::query!( + r#" + UPDATE expenses SET + description = COALESCE($1, description), + price = COALESCE($2, price), + category = COALESCE($3 :: expense_category, category), + is_essential = COALESCE($4, is_essential), + date = COALESCE($5, date) + WHERE uuid = $6 AND user_id = $7 + and deleted_at is null + "#, + p.description, + p.price, + p.category as Option, + p.is_essential, + p.date, + expense_uuid, + user_id + ) + .execute(db_pool) + .await +} + +pub async fn delete( + db_pool: &Pool, + user_id: i32, + expense_uuid: Uuid, + now: DateTime, +) -> Result { + sqlx::query!( + r#" + update expenses + set deleted_at = $1 + WHERE uuid = $2 AND user_id = $3 + "#, + now, + expense_uuid, + user_id, + ) + .execute(db_pool) + .await +} diff --git a/src/queries/mod.rs b/src/queries/mod.rs new file mode 100644 index 0000000..2624880 --- /dev/null +++ b/src/queries/mod.rs @@ -0,0 +1 @@ +pub mod expenses; From 263cb6284c1996d6af7db321aafe1d921a9f6c19 Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 13:28:14 -0300 Subject: [PATCH 03/18] feat: implementing the new structure for expenses hypermedia api --- src/features/expenses.rs | 70 +++++- src/hypermedia/router/expenses.rs | 275 ++++++++++++++++---- src/hypermedia/service/expenses.rs | 389 ----------------------------- src/hypermedia/service/mod.rs | 3 - src/queries/expenses.rs | 31 +++ 5 files changed, 320 insertions(+), 448 deletions(-) delete mode 100644 src/hypermedia/service/expenses.rs diff --git a/src/features/expenses.rs b/src/features/expenses.rs index 59bdc8e..776e5b1 100644 --- a/src/features/expenses.rs +++ b/src/features/expenses.rs @@ -1,8 +1,10 @@ use chrono::NaiveDate; +use plotly::{Layout, Plot, Scatter}; use sqlx::{Pool, Postgres}; +use std::collections::BTreeMap; use uuid::Uuid; -use crate::schema::{CreateExpense, UpdateExpenseApi}; +use crate::schema::{CreateExpense, UpdateExpenseApi, UpdateExpenseHypr}; pub async fn create( user_id: i32, @@ -66,6 +68,42 @@ pub async fn update( })? } +pub enum UpdateOutcome { + Success(crate::queries::expenses::Expense), + NotFound, +} + +pub async fn update_for_site( + user_id: i32, + db_pool: Pool, + expense_uuid: Uuid, + update: UpdateExpenseHypr, +) -> anyhow::Result { + if let Some(exists) = + crate::queries::expenses::exists_active(&db_pool, user_id, expense_uuid).await? + { + if !exists { + tracing::warn!("entered via Some false"); + return Ok(UpdateOutcome::NotFound); + } + } else { + tracing::warn!("entered via None"); + return Ok(UpdateOutcome::NotFound); + } + + let params = crate::queries::expenses::UpdateParams { + description: update.description, + price: update.price, + category: update.category, + is_essential: update.is_essential, + date: update.date, + }; + let expense = + crate::queries::expenses::update_for_site(&db_pool, user_id, expense_uuid, params).await?; + + Ok(UpdateOutcome::Success(expense)) +} + pub enum DeleteOutcome { Success, NotFound, @@ -98,3 +136,33 @@ pub async fn delete( Ok(DeleteOutcome::Success) } + +pub async fn plot( + user_id: i32, + db_pool: Pool, + from: Option, + to: Option, +) -> Result { + let expenses = + crate::queries::expenses::list_for_user_in_period(&db_pool, user_id, from, to).await?; + + let mut expenses_by_date = BTreeMap::::new(); + for expense in expenses { + let price = expenses_by_date.entry(expense.date).or_insert(0.0); + *price += expense.price; + } + + let trace = Scatter::new( + expenses_by_date.keys().copied().collect(), + expenses_by_date.values().copied().collect(), + ) + .name("Expenses"); + + let mut plot = Plot::new(); + plot.add_trace(trace); + + let layout = Layout::new().title("Expenses"); + plot.set_layout(layout); + + Ok(plot) +} diff --git a/src/hypermedia/router/expenses.rs b/src/hypermedia/router/expenses.rs index f5a2071..f28d69c 100644 --- a/src/hypermedia/router/expenses.rs +++ b/src/hypermedia/router/expenses.rs @@ -1,15 +1,20 @@ use crate::{ auth::AuthSession, - schema::{GetExpense, UpdateExpense}, - templates::ExpensesTemplate, + constant::TABLE_ROW, + schema::{CreateExpense, ExpenseCategory, GetExpense, UpdateExpenseHypr}, + templates::{ + DeleteExpenseModal, EditableExpenseRowTemplate, ExpenseRowTemplate, ExpensesTemplate, + }, AppState, }; use std::sync::Arc; +use strum::IntoEnumIterator; use askama_axum::IntoResponse; use axum::{ extract::{Path, Query, State}, http::StatusCode, + response::{Html, Response}, routing::get, Form, Router, }; @@ -18,14 +23,11 @@ use uuid::Uuid; pub fn router() -> Router> { Router::new() .route("/", get(expenses_index)) - .route("/expenses", get(get_expenses).post(insert_expense)) - .route("/expenses/:uuid/edit", get(edit_expense)) + .route("/expenses", get(list).post(create)) + .route("/expenses/:uuid/edit", get(editable_expense)) .route("/expenses/:uuid/delete-modal", get(remove_expense_modal)) - .route( - "/expenses/:uuid", - get(get_expense).put(update_expense).delete(delete_expense), - ) - .route("/expenses/plots", get(expenses_plots)) + .route("/expenses/:uuid", get(find).put(update).delete(delete)) + .route("/expenses/plots", get(plots)) .route("/expenses/pluggy-widget", get(pluggy_widget)) } @@ -40,96 +42,259 @@ async fn expenses_index(auth_session: AuthSession) -> impl IntoResponse { } } -async fn get_expenses( +async fn list( auth_session: AuthSession, State(shared_state): State>, - Query(get_expense_input): Query, -) -> impl IntoResponse { - crate::hypermedia::service::expenses::get_expenses( - auth_session, - &shared_state.pool, - get_expense_input, + Query(get_expenses_input): Query, +) -> Result { + let user_id = if let Some(user) = auth_session.user { + tracing::info!("User logged in"); + user.id + } else { + tracing::error!("User not logged in"); + return Err(StatusCode::UNAUTHORIZED.into_response()); + }; + + match crate::features::expenses::list_in_period( + user_id, + shared_state.pool.clone(), + get_expenses_input.from, + get_expenses_input.to, ) .await + { + Ok(expenses) => Ok(( + [("HX-Trigger", "plot-data")], + Html( + expenses + .iter() + .map(|expense| { + format!( + TABLE_ROW!(), + expense.date, + expense.description, + expense.price, + expense.category, + expense.is_essential, + expense.uuid + ) + }) + .collect::>() + .join("\n"), + ), + ) + .into_response()), + Err(e) => { + tracing::error!(user_id, "Error inserting expense: {}", e); + Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()) + } + } } -async fn edit_expense( +async fn editable_expense( auth_session: AuthSession, Path(uuid): Path, State(shared_state): State>, -) -> impl IntoResponse { - crate::hypermedia::service::expenses::edit_expense(auth_session, &shared_state.pool, uuid).await +) -> Result { + let user_id = if let Some(user) = auth_session.user { + tracing::info!("User logged in"); + user.id + } else { + tracing::error!("User not logged in"); + return Err(StatusCode::UNAUTHORIZED.into_response()); + }; + + match crate::features::expenses::find_active_for_user(user_id, shared_state.pool.clone(), uuid) + .await + { + Ok(expense) => Ok(EditableExpenseRowTemplate { + date: expense.date, + description: expense.description, + price: expense.price, + current_category: expense.category, + is_essential: if expense.is_essential { "checked" } else { "" }, + uuid: expense.uuid, + expense_categories: ExpenseCategory::iter(), + }), + Err(e) => { + tracing::error!(user_id, "Error inserting expense: {}", e); + Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()) + } + } } -async fn get_expense( +async fn find( auth_session: AuthSession, Path(uuid): Path, State(shared_state): State>, -) -> impl IntoResponse { - crate::hypermedia::service::expenses::get_expense(auth_session, &shared_state.pool, uuid).await +) -> Result { + let user_id = if let Some(user) = auth_session.user { + tracing::info!("User logged in"); + user.id + } else { + tracing::error!("User not logged in"); + return Err(StatusCode::UNAUTHORIZED.into_response()); + }; + + match crate::features::expenses::find_active_for_user(user_id, shared_state.pool.clone(), uuid) + .await + { + Ok(expense) => Ok(ExpenseRowTemplate { + date: expense.date, + description: expense.description, + price: expense.price, + category: expense.category, + is_essential: expense.is_essential, + uuid: expense.uuid, + }), + Err(e) => { + tracing::error!(user_id, "Error inserting expense: {}", e); + Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()) + } + } } -async fn update_expense( +async fn update( auth_session: AuthSession, Path(uuid): Path, State(shared_state): State>, - Form(update_expense): Form, -) -> impl IntoResponse { - crate::hypermedia::service::expenses::update_expense( - auth_session, - &shared_state.pool, + Form(update_expense): Form, +) -> Result { + let user_id = if let Some(user) = auth_session.user { + tracing::info!("User logged in"); + user.id + } else { + tracing::error!("User not logged in"); + return Err(StatusCode::UNAUTHORIZED.into_response()); + }; + + let outcome = crate::features::expenses::update_for_site( + user_id, + shared_state.pool.clone(), uuid, update_expense, ) .await + .map_err(|e| { + tracing::error!(user_id, "Error inserting expense: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + })?; + + match outcome { + crate::features::expenses::UpdateOutcome::Success(updated) => Ok(( + [("HX-Trigger", "refresh-plots")], + ExpenseRowTemplate { + date: updated.date, + description: updated.description, + price: updated.price, + category: updated.category, + is_essential: updated.is_essential, + uuid: updated.uuid, + }, + ) + .into_response()), + crate::features::expenses::UpdateOutcome::NotFound => { + Ok(StatusCode::NOT_FOUND.into_response()) + } + } } -async fn delete_expense( +async fn delete( auth_session: AuthSession, Path(uuid): Path, State(shared_state): State>, -) -> impl IntoResponse { - crate::hypermedia::service::expenses::delete_expense(auth_session, &shared_state.pool, uuid) +) -> Result { + let user_id = if let Some(user) = auth_session.user { + tracing::info!("User logged in"); + user.id + } else { + tracing::error!("User not logged in"); + return Err(StatusCode::UNAUTHORIZED.into_response()); + }; + + let outcome = crate::features::expenses::delete(user_id, shared_state.pool.clone(), uuid) .await + .map_err(|e| { + tracing::error!(user_id, "Error inserting expense: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + })?; + + match outcome { + crate::features::expenses::DeleteOutcome::Success => { + Ok((StatusCode::OK, [("HX-Trigger", "refresh-table")]).into_response()) + } + crate::features::expenses::DeleteOutcome::NotFound => { + Ok(StatusCode::NOT_FOUND.into_response()) + } + } } async fn remove_expense_modal( auth_session: AuthSession, - State(shared_state): State>, Path(uuid): Path, -) -> impl IntoResponse { - crate::hypermedia::service::expenses::remove_expense_modal( - auth_session, - &shared_state.pool, - uuid, - ) - .await +) -> Result { + if let Some(user) = auth_session.user { + tracing::info!("User logged in"); + user.id + } else { + tracing::error!("User not logged in"); + return Err(StatusCode::UNAUTHORIZED); + }; + + Ok(DeleteExpenseModal { expense_uuid: uuid }) } -async fn insert_expense( +async fn create( auth_session: AuthSession, State(shared_state): State>, - Form(create_expense): Form, -) -> impl IntoResponse { - crate::hypermedia::service::expenses::insert_expense( - auth_session, - &shared_state.pool, - create_expense, - ) - .await + Form(create_expense): Form, +) -> Result { + let user_id = if let Some(user) = auth_session.user { + tracing::info!("User logged in"); + user.id + } else { + tracing::error!("User not logged in"); + return Err(StatusCode::UNAUTHORIZED.into_response()); + }; + + match crate::features::expenses::create(user_id, shared_state.pool.clone(), create_expense) + .await + { + Ok(()) => Ok([("HX-Trigger", "refresh-table")]), + Err(e) => { + tracing::error!(user_id, "Error inserting expense: {}", e); + Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()) + } + } } -async fn expenses_plots( +async fn plots( auth_session: AuthSession, State(shared_state): State>, - Query(get_expense_input): Query, -) -> impl IntoResponse { - crate::hypermedia::service::expenses::plot_expenses( - auth_session, - &shared_state.pool, - get_expense_input, + Query(get_expenses): Query, +) -> Result { + let user_id = if let Some(user) = auth_session.user { + tracing::info!("User logged in"); + user.id + } else { + tracing::error!("User not logged in"); + return Err(StatusCode::UNAUTHORIZED.into_response()); + }; + + match crate::features::expenses::plot( + user_id, + shared_state.pool.clone(), + get_expenses.from, + get_expenses.to, ) .await + { + Ok(plot) => Ok(plot.to_inline_html(Some("plot-data"))), + Err(e) => { + tracing::error!(user_id, "Error inserting expense: {}", e); + Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()) + } + } } async fn pluggy_widget( diff --git a/src/hypermedia/service/expenses.rs b/src/hypermedia/service/expenses.rs deleted file mode 100644 index 89579fc..0000000 --- a/src/hypermedia/service/expenses.rs +++ /dev/null @@ -1,389 +0,0 @@ -use std::collections::BTreeMap; - -use crate::{ - auth::AuthSession, - constant::TABLE_ROW, - schema::{Expense, ExpenseCategory, GetExpense, UpdateExpense}, - templates::{DeleteExpenseModal, EditableExpenseRowTemplate, ExpenseRowTemplate}, - util::{get_first_day_from_month_or_none, get_last_day_from_month_or_none}, -}; - -use askama_axum::IntoResponse; -use axum::{http::StatusCode, response::Html}; -use chrono::NaiveDate; -use plotly::{Layout, Plot, Scatter}; -use sqlx::{Pool, Postgres}; -use uuid::Uuid; - -pub async fn get_expenses( - auth_session: AuthSession, - db_pool: &Pool, - get_expense_input: GetExpense, -) -> impl IntoResponse { - let user_id = if let Some(user) = auth_session.user { - tracing::info!("User logged in"); - user.id - } else { - tracing::error!("User not logged in"); - return StatusCode::UNAUTHORIZED.into_response(); - }; - - let first_day_of_month = match get_first_day_from_month_or_none(get_expense_input.month.clone()) - { - Ok(date) => date, - Err(e) => { - tracing::error!("Error getting first day of month: {}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - let last_day_of_month = match get_last_day_from_month_or_none(get_expense_input.month) { - Ok(date) => date, - Err(e) => { - tracing::error!("Error getting last day of month: {}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - let expenses = sqlx::query_as!( - Expense, - r#"SELECT id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid - FROM expenses - WHERE ((date >= $1) OR ($1 IS NULL)) - AND ((date <= $2) OR ($2 IS NULL)) - AND user_id = $3 - ORDER BY date ASC"#, - first_day_of_month, - last_day_of_month, - user_id - ) - .fetch_all(db_pool) - .await - .unwrap(); - - return ( - StatusCode::OK, - [("HX-Trigger", "plot-data")], - Html( - expenses - .iter() - .map(|expense| { - format!( - TABLE_ROW!(), - expense.date, - expense.description, - expense.price, - expense.category, - expense.is_essential, - expense.uuid - ) - }) - .collect::>() - .join("\n"), - ), - ) - .into_response(); -} - -pub async fn edit_expense( - auth_session: AuthSession, - db_pool: &Pool, - uuid: Uuid, -) -> impl IntoResponse { - let user_id = if let Some(user) = auth_session.user { - tracing::info!("User logged in"); - user.id - } else { - tracing::error!("User not logged in"); - return StatusCode::UNAUTHORIZED.into_response(); - }; - - let expense = sqlx::query_as!( - Expense, - r#"SELECT id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid - FROM expenses WHERE uuid = $1 AND user_id = $2"#, - uuid, - user_id - ) - .fetch_one(db_pool) - .await - .unwrap(); - - return EditableExpenseRowTemplate { - uuid: expense.uuid, - date: expense.date, - description: expense.description, - price: expense.price, - is_essential: if expense.is_essential { "checked" } else { "" }, - current_category: expense.category, - ..Default::default() - } - .into_response(); -} - -pub async fn get_expense( - auth_session: AuthSession, - db_pool: &Pool, - uuid: Uuid, -) -> impl IntoResponse { - let user_id = if let Some(user) = auth_session.user { - tracing::info!("User logged in"); - user.id - } else { - tracing::error!("User not logged in"); - return StatusCode::UNAUTHORIZED.into_response(); - }; - - let expense = sqlx::query_as!( - Expense, - r#"SELECT id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid - FROM expenses WHERE uuid = $1 AND user_id = $2"#, - uuid, - user_id - ) - .fetch_one(db_pool) - .await - .unwrap(); - - return ExpenseRowTemplate { - date: expense.date, - description: expense.description, - price: expense.price, - category: expense.category, - is_essential: expense.is_essential, - uuid: expense.uuid, - } - .into_response(); -} - -pub async fn update_expense( - auth_session: AuthSession, - db_pool: &Pool, - uuid: Uuid, - update_expense: UpdateExpense, -) -> impl IntoResponse { - let user_id = if let Some(user) = auth_session.user { - tracing::info!("User logged in"); - user.id - } else { - tracing::error!("User not logged in"); - return StatusCode::UNAUTHORIZED.into_response(); - }; - - match sqlx::query_as!( - Expense, - r#" - UPDATE expenses SET - description = COALESCE($1, description), - price = COALESCE($2, price), - category = COALESCE($3 :: expense_category, category), - is_essential = COALESCE($4, is_essential), - date = COALESCE($5, date) - WHERE uuid = $6 AND user_id = $7 - RETURNING id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid - "#, - update_expense.description, - update_expense.price, - update_expense.category as Option, - update_expense.is_essential, - update_expense.date, - uuid, - user_id - ) - .fetch_one(db_pool) - .await { - Ok(expense) => { - ( - StatusCode::OK, - [("HX-Trigger", "refresh-plots")], - ExpenseRowTemplate { - date: expense.date, - description: expense.description, - price: expense.price, - category: expense.category, - is_essential: expense.is_essential, - uuid: expense.uuid - } - ).into_response() - }, - Err(e) => { - tracing::error!("Error updating expense: {}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - }, - } -} - -pub async fn delete_expense( - auth_session: AuthSession, - db_pool: &Pool, - uuid: Uuid, -) -> impl IntoResponse { - let user_id = if let Some(user) = auth_session.user { - tracing::info!("User logged in"); - user.id - } else { - tracing::error!("User not logged in"); - return StatusCode::UNAUTHORIZED.into_response(); - }; - - match sqlx::query!( - r#" - DELETE FROM expenses - WHERE uuid = $1 AND user_id = $2 - "#, - uuid, - user_id - ) - .execute(db_pool) - .await - { - Ok(_) => (StatusCode::OK, [("HX-Trigger", "refresh-table")]).into_response(), - Err(e) => { - tracing::error!("Error deleting expense: {}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } -} - -pub async fn remove_expense_modal( - auth_session: AuthSession, - db_pool: &Pool, - uuid: Uuid, -) -> impl IntoResponse { - let user_id = if let Some(user) = auth_session.user { - tracing::info!("User logged in"); - user.id - } else { - tracing::error!("User not logged in"); - return StatusCode::UNAUTHORIZED.into_response(); - }; - - let expense = sqlx::query_as!( - Expense, - r#"SELECT id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid - FROM expenses WHERE uuid = $1 AND user_id = $2"#, - uuid, - user_id - ) - .fetch_one(db_pool) - .await - .unwrap(); - - DeleteExpenseModal { - expense_uuid: expense.uuid, - } - .into_response() -} - -pub async fn insert_expense( - auth_session: AuthSession, - db_pool: &Pool, - create_expense: UpdateExpense, -) -> impl IntoResponse { - let user_id = if let Some(user) = auth_session.user { - tracing::info!("User logged in"); - user.id - } else { - tracing::error!("User not logged in"); - return StatusCode::UNAUTHORIZED.into_response(); - }; - - match sqlx::query_as!( - Expense, - r#" - INSERT INTO expenses (description, price, category, is_essential, date, uuid, user_id) - VALUES ($1, $2, $3 :: expense_category, $4, $5, $6, $7) - RETURNING id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid - "#, - create_expense.description, - create_expense.price, - create_expense.category as Option, - create_expense.is_essential, - create_expense.date, - Uuid::new_v4(), - user_id - ) - .fetch_one(db_pool) - .await { - Ok(_) => ( - StatusCode::CREATED, - [("HX-Trigger", "refresh-table")], - "Created", - ).into_response(), - Err(e) => { - tracing::error!("Error inserting expense: {}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - }, - } -} - -/// Plots the expenses of the user. -/// This plot is a time series of the expenses of the user. -pub async fn plot_expenses( - auth_session: AuthSession, - db_pool: &Pool, - get_expense_input: GetExpense, -) -> impl IntoResponse { - let user_id = if let Some(user) = auth_session.user { - tracing::info!("User logged in"); - user.id - } else { - tracing::error!("User not logged in"); - return StatusCode::UNAUTHORIZED.into_response(); - }; - - let first_day_of_month = match get_first_day_from_month_or_none(get_expense_input.month.clone()) - { - Ok(date) => date, - Err(e) => { - tracing::error!("Error getting first day of month: {}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - let last_day_of_month = match get_last_day_from_month_or_none(get_expense_input.month) { - Ok(date) => date, - Err(e) => { - tracing::error!("Error getting last day of month: {}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - let expenses = match sqlx::query_as!( - Expense, - r#"SELECT id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid - FROM expenses - WHERE ((date >= $1) OR ($1 IS NULL)) - AND ((date <= $2) OR ($2 IS NULL)) - AND user_id = $3 - ORDER BY date ASC"#, - first_day_of_month, - last_day_of_month, - user_id - ) - .fetch_all(db_pool) - .await - { - Ok(expenses) => expenses, - Err(e) => { - tracing::error!("Error getting expenses: {}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - - let mut expenses_by_date = BTreeMap::::new(); - for expense in expenses { - let price = expenses_by_date.entry(expense.date).or_insert(0.0); - *price += expense.price; - } - - let trace = Scatter::new( - expenses_by_date.keys().copied().collect(), - expenses_by_date.values().copied().collect(), - ) - .name("Expenses"); - - let mut plot = Plot::new(); - plot.add_trace(trace); - - let layout = Layout::new().title("Expenses"); - plot.set_layout(layout); - - plot.to_inline_html(Some("plot-data")).into_response() -} diff --git a/src/hypermedia/service/mod.rs b/src/hypermedia/service/mod.rs index 89b439a..9251290 100644 --- a/src/hypermedia/service/mod.rs +++ b/src/hypermedia/service/mod.rs @@ -1,9 +1,6 @@ /// Module that has the authentication features. /// This module is public and protected. pub mod auth; -/// Module that has the expenses features. -/// This module is restrited. -pub mod expenses; /// Module that performs input validation. /// This module is public. pub mod validation; diff --git a/src/queries/expenses.rs b/src/queries/expenses.rs index 2daa7a1..f69085f 100644 --- a/src/queries/expenses.rs +++ b/src/queries/expenses.rs @@ -144,6 +144,37 @@ pub async fn update( .await } +pub async fn update_for_site( + db_pool: &Pool, + user_id: i32, + expense_uuid: Uuid, + p: UpdateParams, +) -> Result { + sqlx::query_as!( + Expense, + r#" + UPDATE expenses SET + description = COALESCE($1, description), + price = COALESCE($2, price), + category = COALESCE($3 :: expense_category, category), + is_essential = COALESCE($4, is_essential), + date = COALESCE($5, date) + WHERE uuid = $6 AND user_id = $7 + and deleted_at is null + RETURNING id, description, price, category as "category: ExpenseCategory", is_essential, date, uuid + "#, + p.description, + p.price, + p.category as Option, + p.is_essential, + p.date, + expense_uuid, + user_id + ) + .fetch_one(db_pool) + .await +} + pub async fn delete( db_pool: &Pool, user_id: i32, From e7b722f553104707b163ea9189a790d20d5c5b93 Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 13:29:28 -0300 Subject: [PATCH 04/18] refa: simplifying get expenses, user now inputs min and max bounds directly on datetimes, instead of selecting a month and us converting it for them --- src/data_structs.rs | 38 +++++++++++++------------- src/main.rs | 2 +- src/schema.rs | 37 ++++++++++++++++--------- src/templates.rs | 21 +-------------- src/util.rs | 60 ----------------------------------------- templates/expenses.html | 27 +++++++++---------- 6 files changed, 56 insertions(+), 129 deletions(-) diff --git a/src/data_structs.rs b/src/data_structs.rs index 08660a0..42bab50 100644 --- a/src/data_structs.rs +++ b/src/data_structs.rs @@ -61,30 +61,28 @@ impl Display for Months { } } -impl TryInto for Months { - type Error = &'static str; - - fn try_into(self) -> Result { - match self { - Self::January => Ok(1), - Self::February => Ok(2), - Self::March => Ok(3), - Self::April => Ok(4), - Self::May => Ok(5), - Self::June => Ok(6), - Self::July => Ok(7), - Self::August => Ok(8), - Self::September => Ok(9), - Self::October => Ok(10), - Self::November => Ok(11), - Self::December => Ok(12), +impl From for u32 { + fn from(value: Months) -> Self { + match value { + Months::January => 1, + Months::February => 2, + Months::March => 3, + Months::April => 4, + Months::May => 5, + Months::June => 6, + Months::July => 7, + Months::August => 8, + Months::September => 9, + Months::October => 10, + Months::November => 11, + Months::December => 12, } } } -impl Months { - pub const fn from_chrono_month(month: Month) -> Self { - match month { +impl From for Months { + fn from(value: chrono::Month) -> Self { + match value { Month::January => Self::January, Month::February => Self::February, Month::March => Self::March, diff --git a/src/main.rs b/src/main.rs index 64f117a..5444a26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ mod templates; /// Module containing time and crypto utility functions. mod util; -use crate::{auth::Backend, data_structs::Months}; +use crate::auth::Backend; use std::{net::SocketAddr, sync::Arc, time::Duration}; use anyhow::bail; diff --git a/src/schema.rs b/src/schema.rs index 42ce036..9d98d15 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,4 +1,3 @@ -use crate::Months; use std::{ fmt::{self, Display}, str::FromStr, @@ -66,16 +65,17 @@ pub struct Expense { #[derive(Deserialize, Debug)] /// `GetExpense` is a struct with the fields of an expense that can be retrieved. -/// Currently, only the month is supported and it is optional. -/// If no month is passed, all expenses are retrieved. pub struct GetExpense { #[serde(deserialize_with = "empty_string_to_none")] - pub month: Option, + pub from: Option, + #[serde(deserialize_with = "empty_string_to_none")] + pub to: Option, } #[derive(Deserialize, Debug)] /// `UpdateExpense` is a struct with the fields of an expense that can be updated. /// All fields are optional. +/// This is part of the contract of the Data API. pub struct UpdateExpenseApi { pub description: Option, pub price: Option, @@ -84,10 +84,20 @@ pub struct UpdateExpenseApi { pub date: Option, } -#[derive(Deserialize, Debug, Default)] +#[derive(Deserialize, Debug)] +pub struct CreateExpense { + pub description: String, + pub price: f32, + pub category: ExpenseCategory, + pub is_essential: bool, + pub date: NaiveDate, +} + +#[derive(Deserialize, Debug)] /// `UpdateExpense` is a struct with the fields of an expense that can be updated. /// All fields are optional. -pub struct UpdateExpense { +/// This is part of the contract of the Hypermedia API. +pub struct UpdateExpenseHypr { pub description: Option, #[serde(deserialize_with = "de_string_to_option_f32")] pub price: Option, @@ -101,11 +111,10 @@ pub struct UpdateExpense { } /// Function to set the default value for `is_essential` in `UpdateExpense` to be Some(false). -#[allow(clippy::unnecessary_wraps)] -//#[allow( -// clippy::unnecessary_wraps, -// reason = "Needs to return option for custom deserializer" -//)] +#[expect( + clippy::unnecessary_wraps, + reason = "Needs to return option for custom deserializer" +)] const fn default_is_essential_to_false() -> Option { return Some(false); } @@ -130,7 +139,7 @@ where /// If the string is empty, returns None. /// If the string is a valid month, returns Some(Months). /// If the string is not a valid month, returns an error. -fn empty_string_to_none<'de, D>(deserializer: D) -> Result, D::Error> +fn empty_string_to_none<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { @@ -138,7 +147,9 @@ where if str.is_empty() { return Ok(None); } - return Ok(Some(Months::from_str(&str).map_err(de::Error::custom)?)); + return Ok(Some( + NaiveDate::parse_from_str(&str, "%Y-%m-%d").map_err(de::Error::custom)?, + )); } /// Deserialization serde function that parses a f32 from a string. diff --git a/src/templates.rs b/src/templates.rs index 009bec2..eb421a0 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,12 +1,11 @@ use crate::{ - data_structs::{Months, MonthsIter}, schema::{ExpenseCategory, ExpenseCategoryIter}, util::{add_csp_to_response, generate_otp_token}, }; use askama_axum::{IntoResponse, Template}; use axum::{body::Body, http::Response}; -use chrono::{Datelike, Month, NaiveDate, Utc}; +use chrono::NaiveDate; use strum::IntoEnumIterator; use uuid::Uuid; @@ -14,12 +13,8 @@ use uuid::Uuid; #[template(path = "expenses.html")] /// The askama template for the expenses page. pub struct ExpensesTemplate { - /// The current month to be displayed in English in the dropdown. - pub current_month: Months, /// The expense types to be displayed in the dropdown. pub expense_categories: ExpenseCategoryIter, - /// The months to be displayed in the dropdown. - pub months: MonthsIter, /// The username of the logged in user. pub username: String, /// CSP nonce @@ -29,19 +24,7 @@ pub struct ExpensesTemplate { impl Default for ExpensesTemplate { fn default() -> Self { return Self { - current_month: { - Months::from_chrono_month( - Month::try_from(u8::try_from(Utc::now().month()).unwrap_or_else(|_| { - tracing::error!( - "Failed to convert chrono month to u8, defaulting to 1(January)" - ); - return 1; - })) - .unwrap_or(Month::January), - ) - }, expense_categories: ExpenseCategory::iter(), - months: Months::iter(), username: String::new(), nonce: String::new(), }; @@ -56,9 +39,7 @@ impl ExpensesTemplate { let nonce_str = format!("'nonce-{nonce}'"); let mut response = Self { - current_month: self.current_month, expense_categories: self.expense_categories, - months: self.months, username: self.username, nonce, } diff --git a/src/util.rs b/src/util.rs index 9e6379c..35ff1f0 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,67 +1,7 @@ -use crate::data_structs::Months; - use axum::{body::Body, http::Response}; use axum_helmet::ContentSecurityPolicy; -use chrono::{Datelike, NaiveDate, Utc}; use rand::{distributions::Alphanumeric, thread_rng, Rng}; -/// Returns the first day of a month passed as an unsigned integer as a `NaiveDate`. -/// Example: `get_first_day_from_month(1)` returns 2023-01-01. -pub fn get_first_day_from_month(month: u32) -> Option { - return Utc::now() - .with_day(1) - .and_then(|date| return date.naive_utc().date().with_month(month)); -} - -/// If a month is passed, as a Some(Months), returns the first day of that month as a `NaiveDate`. -/// If None is passed, returns None. -/// Months is an enum with the months of the year. -pub fn get_first_day_from_month_or_none( - option_month: Option, -) -> Result, &'static str> { - let Some(month) = option_month else { - return Ok(None); - }; - let month_n: u32 = month - .try_into() - .map_err(|_ignore| return "Could not convert month to u32.")?; - match get_first_day_from_month(month_n) { - Some(date) => return Ok(Some(date)), - None => return Err("Could not get first day from month."), - } -} - -/// Returns the last day of a month passed as an unsigned integer as a `NaiveDate`. -/// Example: `get_last_day_from_month(1)` returns 2023-01-31. -pub fn get_last_day_from_month(month: u32) -> Option { - let first_day_of_month = get_first_day_from_month(month)?; - - if first_day_of_month.month() == 12 { - return first_day_of_month.with_day(31); - } - return first_day_of_month - .with_month(month.checked_add(1)?) - .and_then(|date| return date.checked_sub_days(chrono::Days::new(1))); -} - -/// If a month is passed, as a Some(Months), returns the last day of that month as a `NaiveDate`. -/// If None is passed, returns None. -/// Months is an enum with the months of the year. -pub fn get_last_day_from_month_or_none( - option_month: Option, -) -> Result, &'static str> { - let Some(month) = option_month else { - return Ok(None); - }; - let month_n: u32 = month - .try_into() - .map_err(|_ignore| return "Could not convert month to u32.")?; - match get_last_day_from_month(month_n) { - Some(date) => return Ok(Some(date)), - None => return Err("Could not get last day from month, maybe it underflowed?"), - } -} - /// Generates a random string of 128 characters to be used as an email verification token. pub fn generate_verification_token() -> String { return thread_rng() diff --git a/templates/expenses.html b/templates/expenses.html index e7875f7..af6b3b5 100644 --- a/templates/expenses.html +++ b/templates/expenses.html @@ -28,18 +28,15 @@

{{ username }}'s Expenses

-
+
- + + +
@@ -47,8 +44,8 @@

{{ username }}'s Expenses

@@ -76,8 +73,8 @@

{{ username }}'s Expenses

From f6a5e185afcf2822add59af7228747b508ac1d9e Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 13:35:25 -0300 Subject: [PATCH 05/18] refa: changing clippy allows to expects and adding reasons --- Cargo.toml | 3 ++- src/client/pluggy/account.rs | 3 +-- src/client/pluggy/transactions.rs | 3 +-- src/hypermedia/service/auth.rs | 2 +- src/main.rs | 5 ++++- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 89c6533..7ce452c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,8 @@ all = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = 0 } # unwrap_used = { level = "warn", priority = 1 } -# allow_attributes_without_reason = { level = "warn", priority = 4 } # nightly +allow_attributes = { level = "warn", priority = 4 } +allow_attributes_without_reason = { level = "warn", priority = 4 } as_underscore = { level = "warn", priority = 4 } panic_in_result_fn = { level = "warn", priority = 4 } diff --git a/src/client/pluggy/account.rs b/src/client/pluggy/account.rs index 08fad09..410a9f7 100644 --- a/src/client/pluggy/account.rs +++ b/src/client/pluggy/account.rs @@ -20,8 +20,7 @@ pub struct ListAccountsResponse { pub struct Account { id: Uuid, #[serde(rename = "type")] - #[allow(clippy::struct_field_names)] - account_type: AccountType, + acc_type: AccountType, subtype: AccountSubType, /// External identifier of the account: agencia/conta number: String, diff --git a/src/client/pluggy/transactions.rs b/src/client/pluggy/transactions.rs index 53323a0..d9a1929 100644 --- a/src/client/pluggy/transactions.rs +++ b/src/client/pluggy/transactions.rs @@ -35,8 +35,7 @@ pub struct Transaction { /// Can be used to identify the category in the Categories endpoint category_id: Option, #[serde(rename = "type")] - #[allow(clippy::struct_field_names)] - transaction_type: TransactionType, + tx_type: TransactionType, /// Balance after the transaction balance: f32, /// Institution provided code diff --git a/src/hypermedia/service/auth.rs b/src/hypermedia/service/auth.rs index 0de88f5..da05a7c 100644 --- a/src/hypermedia/service/auth.rs +++ b/src/hypermedia/service/auth.rs @@ -218,7 +218,7 @@ pub fn signup_tab(secrets: &Secrets) -> impl IntoResponse { .into_response_with_nonce(); } -#[allow(clippy::too_many_lines)] +#[expect(clippy::too_many_lines, reason = "not yet reviewed this function")] pub async fn signup( db_pool: &Pool, secrets: &Secrets, diff --git a/src/main.rs b/src/main.rs index 5444a26..fac45a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -118,7 +118,10 @@ struct AppState { } #[shuttle_runtime::main] -#[allow(clippy::too_many_lines)] +#[expect( + clippy::too_many_lines, + reason = "I have to think on how to shrink it, idk" +)] /// The main function of the application. async fn axum( #[shuttle_runtime::Secrets] secret_store: SecretStore, From 89eb23a179ef5459ebd1ec278590ba4f6e9d9712 Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 13:41:32 -0300 Subject: [PATCH 06/18] bump: cargo update --- Cargo.lock | 184 +++++++++++++++++++++++++---------------------------- Cargo.toml | 4 +- 2 files changed, 88 insertions(+), 100 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5dcd094..d4739ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,12 +11,6 @@ dependencies = [ "gimli", ] -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "adler2" version = "2.0.0" @@ -103,7 +97,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a41603f7cdbf5ac4af60760f17253eb6adf6ec5b6f14a7ed830cf687d375f163" dependencies = [ "askama", - "axum-core 0.4.4", + "axum-core 0.4.5", "http 1.1.0", ] @@ -118,7 +112,7 @@ dependencies = [ "mime_guess", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -155,7 +149,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -166,7 +160,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -180,9 +174,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" @@ -214,12 +208,12 @@ dependencies = [ [[package]] name = "axum" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f43644eed690f5374f1af436ecd6aea01cd201f6fbdf0178adaf6907afb2cec" +checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae" dependencies = [ "async-trait", - "axum-core 0.4.4", + "axum-core 0.4.5", "bytes", "futures-util", "http 1.1.0", @@ -265,9 +259,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6b8ba012a258d63c9adfa28b9ddcf66149da6f986c5b5452e629d5ee64bf00" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ "async-trait", "bytes", @@ -290,7 +284,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39893950267de4634ab036e4bb7efe0f4adeedb39e5f75c04131228bcc1e53a8" dependencies = [ - "axum 0.7.6", + "axum 0.7.7", "helmet-core", "http 1.1.0", "pin-project-lite", @@ -306,7 +300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4012877d9672b7902aa6567960208756f68a09de81e988fa18fe369e92f90471" dependencies = [ "async-trait", - "axum 0.7.6", + "axum 0.7.7", "form_urlencoded", "serde", "subtle", @@ -328,7 +322,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide 0.8.0", + "miniz_oxide", "object", "rustc-demangle", "windows-targets 0.52.6", @@ -423,11 +417,11 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.11.0-rc.1" +version = "0.11.0-rc.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8969801e57d15e15bc4d7cdc5600dc15ca06a9a62b622bd4871c2d21d8aeb42d" +checksum = "939c0e62efa052fb0b2db2c0f7c479ad32e364c192c3aab605a7641de265a1a7" dependencies = [ - "crypto-common 0.2.0-rc.1", + "hybrid-array", ] [[package]] @@ -462,9 +456,9 @@ checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" [[package]] name = "cc" -version = "1.1.21" +version = "1.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0" +checksum = "9540e661f81799159abee814118cc139a2004b3a3aa3ea37724a1b66530b90e0" dependencies = [ "shlex", ] @@ -699,7 +693,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -710,7 +704,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -782,7 +776,7 @@ dependencies = [ "diesel_table_macro_syntax", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -791,7 +785,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc5557efc453706fed5e4fa85006fe9817c224c3f480a34c7e5959fd700921c5" dependencies = [ - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -812,7 +806,7 @@ version = "0.11.0-pre.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf2e3d6615d99707295a9673e889bf363a04b2a466bd320c65a72536f7577379" dependencies = [ - "block-buffer 0.11.0-rc.1", + "block-buffer 0.11.0-rc.2", "crypto-common 0.2.0-rc.1", "subtle", ] @@ -825,7 +819,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -921,8 +915,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ "bit-set", - "regex-automata 0.4.7", - "regex-syntax 0.8.4", + "regex-automata 0.4.8", + "regex-syntax 0.8.5", ] [[package]] @@ -947,7 +941,7 @@ dependencies = [ "anyhow", "askama", "askama_axum", - "axum 0.7.6", + "axum 0.7.7", "axum-helmet", "axum-login", "chrono", @@ -984,12 +978,12 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.33" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", - "miniz_oxide 0.8.0", + "miniz_oxide", ] [[package]] @@ -1110,7 +1104,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1635,7 +1629,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1876,16 +1870,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" -dependencies = [ - "adler", - "simd-adler32", -] - [[package]] name = "miniz_oxide" version = "0.8.0" @@ -1893,6 +1877,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -2038,9 +2023,12 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" +dependencies = [ + "portable-atomic", +] [[package]] name = "once_map" @@ -2077,7 +2065,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2263,7 +2251,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2333,27 +2321,27 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] name = "png" -version = "0.17.13" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06e4b0d3d1312775e782c86c91a111aa1f910cbb65e1337f9975b5f9a554b5e1" +checksum = "52f9d46a34a05a6a57566bc2bfae066ef07585a6e3fa30fbbdff5936380623f0" dependencies = [ "bitflags 1.3.2", "crc32fast", "fdeflate", "flate2", - "miniz_oxide 0.7.4", + "miniz_oxide", ] [[package]] name = "portable-atomic" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d30538d42559de6b034bc76fd6dd4c38961b1ee5c6c56e3808c50128fdbc22ce" +checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" [[package]] name = "postgres-protocol" @@ -2452,7 +2440,7 @@ dependencies = [ "itertools 0.12.1", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2570,14 +2558,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.6" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.7", - "regex-syntax 0.8.4", + "regex-automata 0.4.8", + "regex-syntax 0.8.5", ] [[package]] @@ -2591,13 +2579,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.4", + "regex-syntax 0.8.5", ] [[package]] @@ -2608,9 +2596,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" @@ -2687,7 +2675,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad88e570fdd4107b876624cc18b650c50fce7bf04ecf587e78bc8fc11cfa53d2" dependencies = [ - "axum-core 0.4.4", + "axum-core 0.4.5", "http 1.1.0", "rinja", ] @@ -2708,7 +2696,7 @@ dependencies = [ "rinja_parser", "rustc-hash", "serde", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2821,9 +2809,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" +checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" [[package]] name = "rustls-webpki" @@ -2931,7 +2919,7 @@ checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2964,7 +2952,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -3006,7 +2994,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -3052,7 +3040,7 @@ version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1806f4f5c6add7321d9820b234b5e41910d36a1689a2be7e5ff59708cf4450e6" dependencies = [ - "axum 0.7.6", + "axum 0.7.7", "shuttle-runtime", ] @@ -3065,7 +3053,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -3563,7 +3551,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -3609,9 +3597,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.77" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -3641,14 +3629,14 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] name = "tempfile" -version = "3.12.0" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" dependencies = [ "cfg-if", "fastrand", @@ -3674,7 +3662,7 @@ checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -3779,7 +3767,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -3928,7 +3916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d" dependencies = [ "async-trait", - "axum-core 0.4.4", + "axum-core 0.4.5", "cookie", "futures-util", "http 1.1.0", @@ -4000,7 +3988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6293bf33f1977d5ef422c2e02f909eb2c3d7bf921d93557c40d4f1b130b84aa4" dependencies = [ "async-trait", - "axum-core 0.4.4", + "axum-core 0.4.5", "base64 0.22.1", "futures", "http 1.1.0", @@ -4046,7 +4034,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "313fa625fea5790ed56360a30ea980e41229cf482b4835801a67ef1922bf63b9" dependencies = [ - "axum 0.7.6", + "axum 0.7.7", "forwarded-header-value", "governor", "http 1.1.0", @@ -4076,7 +4064,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -4275,7 +4263,7 @@ checksum = "ee1cd046f83ea2c4e920d6ee9f7c3537ef928d75dce5d84a87c2c5d6b3999a3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -4305,7 +4293,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -4369,7 +4357,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "wasm-bindgen-shared", ] @@ -4403,7 +4391,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -4692,7 +4680,7 @@ checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "synstructure", ] @@ -4714,7 +4702,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -4734,7 +4722,7 @@ checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "synstructure", ] @@ -4763,7 +4751,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7ce452c..30da1c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ categories = ["finance"] anyhow = { version = "1.0.89", default-features = false } askama = { version = "0.12.1", default-features = false, features = ["with-axum", "serde-json"] } askama_axum = { version = "0.4.0", default-features = false } -axum = { version = "0.7.6", default-features = false } +axum = { version = "0.7.7", default-features = false } axum-helmet = { version = "0.1.0", default-features = false } axum-login = { version = "0.15.3", default-features = false } chrono = { version = "0.4.38", default-features = false, features = ["serde"] } @@ -20,7 +20,7 @@ lettre = { version = "0.11.9", default-features = false, features = ["smtp-trans password-auth = { version = "1.1.0-pre.0", default-features = false, features = ["argon2"] } plotly = { version = "0.10.0", default-features=false, features = ["with-axum"] } rand = { version = "0.8.5", default-features = false } -regex = { version = "1.10.6", default-features = false } +regex = { version = "1.11.0", default-features = false } reqwest = { version = "0.12.7", default-features = false, features = ["json", "default-tls"] } serde = { version = "1.0.210", default-features = false, features = ["derive", "serde_derive"] } serde_json = { version = "1.0.128", default-features = false } From 1f9b4005ed1fbdfb4013c42d291930f1d2097584 Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 13:43:26 -0300 Subject: [PATCH 07/18] sqlx: cargo sqlx prepare for offline querying --- ...0521ff2b3865b28c063ff317e6d6caf82d005.json | 34 +++++++ ...5bf41fbd392c26633d012b741340df6d8cd7.json} | 4 +- ...09fd5b01316daddb314d4e00dca3e11f044a.json} | 4 +- ...3f98103f0e3c4398db76799502ee3a0c25779.json | 92 ------------------- ...997e8e55d73db7af45519acc176364feb4d4.json} | 4 +- ...48d199a5c06b0b4845872d61ca9cb049a628c.json | 15 --- ...8e901782a70966a7a9a8049fbd05e4970114f.json | 34 +++++++ ...950026e9dfba6bd19ba001a03975c13024117.json | 16 ++++ ...69fc2c7fbf826765ab9f803fed0f954776868.json | 23 +++++ 9 files changed, 113 insertions(+), 113 deletions(-) create mode 100644 .sqlx/query-0f97cfc9d9961904daa00581e9f0521ff2b3865b28c063ff317e6d6caf82d005.json rename .sqlx/{query-11362d284830b9de70743332c3e6c0610fec70a34cae3c1388b684f86cec292a.json => query-4c00c0c5726aed16c1fe61772b735bf41fbd392c26633d012b741340df6d8cd7.json} (88%) rename .sqlx/{query-d0f241c6ce1214787fffd9c26b97d13bfa3fb8b2734ec1a423813c26e37d2bb5.json => query-5ef6dfd2c4975355144c235f0aa909fd5b01316daddb314d4e00dca3e11f044a.json} (92%) delete mode 100644 .sqlx/query-8af3df736a8bc36a1375a27d1443f98103f0e3c4398db76799502ee3a0c25779.json rename .sqlx/{query-464e88875aee2328e04c3617bd9cc85f7b5389f117a38ea678b5d623a88fe998.json => query-ac8c51d1316f7120d56b03c8881b997e8e55d73db7af45519acc176364feb4d4.json} (89%) delete mode 100644 .sqlx/query-b05830f0e8a59fe8c1e566d368448d199a5c06b0b4845872d61ca9cb049a628c.json create mode 100644 .sqlx/query-de408d7096db52682e68d9db5268e901782a70966a7a9a8049fbd05e4970114f.json create mode 100644 .sqlx/query-e83fbc0ea18db1e75d6621993f1950026e9dfba6bd19ba001a03975c13024117.json create mode 100644 .sqlx/query-f6d77f09cd2971061abca5e008869fc2c7fbf826765ab9f803fed0f954776868.json diff --git a/.sqlx/query-0f97cfc9d9961904daa00581e9f0521ff2b3865b28c063ff317e6d6caf82d005.json b/.sqlx/query-0f97cfc9d9961904daa00581e9f0521ff2b3865b28c063ff317e6d6caf82d005.json new file mode 100644 index 0000000..20b30d7 --- /dev/null +++ b/.sqlx/query-0f97cfc9d9961904daa00581e9f0521ff2b3865b28c063ff317e6d6caf82d005.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE expenses SET\n description = COALESCE($1, description),\n price = COALESCE($2, price),\n category = COALESCE($3 :: expense_category, category),\n is_essential = COALESCE($4, is_essential),\n date = COALESCE($5, date)\n WHERE uuid = $6 AND user_id = $7\n and deleted_at is null\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Float4", + { + "Custom": { + "name": "expense_category", + "kind": { + "Enum": [ + "food", + "transport", + "health", + "education", + "entertainment", + "other" + ] + } + } + }, + "Bool", + "Date", + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "0f97cfc9d9961904daa00581e9f0521ff2b3865b28c063ff317e6d6caf82d005" +} diff --git a/.sqlx/query-11362d284830b9de70743332c3e6c0610fec70a34cae3c1388b684f86cec292a.json b/.sqlx/query-4c00c0c5726aed16c1fe61772b735bf41fbd392c26633d012b741340df6d8cd7.json similarity index 88% rename from .sqlx/query-11362d284830b9de70743332c3e6c0610fec70a34cae3c1388b684f86cec292a.json rename to .sqlx/query-4c00c0c5726aed16c1fe61772b735bf41fbd392c26633d012b741340df6d8cd7.json index 4ec12c0..6f05585 100644 --- a/.sqlx/query-11362d284830b9de70743332c3e6c0610fec70a34cae3c1388b684f86cec292a.json +++ b/.sqlx/query-4c00c0c5726aed16c1fe61772b735bf41fbd392c26633d012b741340df6d8cd7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE expenses SET\n description = COALESCE($1, description),\n price = COALESCE($2, price),\n category = COALESCE($3 :: expense_category, category),\n is_essential = COALESCE($4, is_essential),\n date = COALESCE($5, date)\n WHERE uuid = $6 AND user_id = $7\n RETURNING id, description, price, category as \"category: ExpenseCategory\", is_essential, date, uuid\n ", + "query": "\n UPDATE expenses SET\n description = COALESCE($1, description),\n price = COALESCE($2, price),\n category = COALESCE($3 :: expense_category, category),\n is_essential = COALESCE($4, is_essential),\n date = COALESCE($5, date)\n WHERE uuid = $6 AND user_id = $7\n and deleted_at is null\n RETURNING id, description, price, category as \"category: ExpenseCategory\", is_essential, date, uuid\n ", "describe": { "columns": [ { @@ -88,5 +88,5 @@ false ] }, - "hash": "11362d284830b9de70743332c3e6c0610fec70a34cae3c1388b684f86cec292a" + "hash": "4c00c0c5726aed16c1fe61772b735bf41fbd392c26633d012b741340df6d8cd7" } diff --git a/.sqlx/query-d0f241c6ce1214787fffd9c26b97d13bfa3fb8b2734ec1a423813c26e37d2bb5.json b/.sqlx/query-5ef6dfd2c4975355144c235f0aa909fd5b01316daddb314d4e00dca3e11f044a.json similarity index 92% rename from .sqlx/query-d0f241c6ce1214787fffd9c26b97d13bfa3fb8b2734ec1a423813c26e37d2bb5.json rename to .sqlx/query-5ef6dfd2c4975355144c235f0aa909fd5b01316daddb314d4e00dca3e11f044a.json index b29189f..a657fb7 100644 --- a/.sqlx/query-d0f241c6ce1214787fffd9c26b97d13bfa3fb8b2734ec1a423813c26e37d2bb5.json +++ b/.sqlx/query-5ef6dfd2c4975355144c235f0aa909fd5b01316daddb314d4e00dca3e11f044a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, description, price, category as \"category: ExpenseCategory\", is_essential, date, uuid\n FROM expenses\n WHERE ((date >= $1) OR ($1 IS NULL))\n AND ((date <= $2) OR ($2 IS NULL))\n AND user_id = $3\n ORDER BY date ASC", + "query": "SELECT id, description, price, category as \"category: ExpenseCategory\", is_essential, date, uuid\n FROM expenses\n WHERE ((date >= $1) OR ($1 IS NULL))\n AND ((date <= $2) OR ($2 IS NULL))\n AND user_id = $3\n and deleted_at is null\n ORDER BY date ASC", "describe": { "columns": [ { @@ -70,5 +70,5 @@ false ] }, - "hash": "d0f241c6ce1214787fffd9c26b97d13bfa3fb8b2734ec1a423813c26e37d2bb5" + "hash": "5ef6dfd2c4975355144c235f0aa909fd5b01316daddb314d4e00dca3e11f044a" } diff --git a/.sqlx/query-8af3df736a8bc36a1375a27d1443f98103f0e3c4398db76799502ee3a0c25779.json b/.sqlx/query-8af3df736a8bc36a1375a27d1443f98103f0e3c4398db76799502ee3a0c25779.json deleted file mode 100644 index 0af102d..0000000 --- a/.sqlx/query-8af3df736a8bc36a1375a27d1443f98103f0e3c4398db76799502ee3a0c25779.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO expenses (description, price, category, is_essential, date, uuid, user_id)\n VALUES ($1, $2, $3 :: expense_category, $4, $5, $6, $7)\n RETURNING id, description, price, category as \"category: ExpenseCategory\", is_essential, date, uuid\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "description", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "price", - "type_info": "Float4" - }, - { - "ordinal": 3, - "name": "category: ExpenseCategory", - "type_info": { - "Custom": { - "name": "expense_category", - "kind": { - "Enum": [ - "food", - "transport", - "health", - "education", - "entertainment", - "other" - ] - } - } - } - }, - { - "ordinal": 4, - "name": "is_essential", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "date", - "type_info": "Date" - }, - { - "ordinal": 6, - "name": "uuid", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Varchar", - "Float4", - { - "Custom": { - "name": "expense_category", - "kind": { - "Enum": [ - "food", - "transport", - "health", - "education", - "entertainment", - "other" - ] - } - } - }, - "Bool", - "Date", - "Uuid", - "Int4" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "8af3df736a8bc36a1375a27d1443f98103f0e3c4398db76799502ee3a0c25779" -} diff --git a/.sqlx/query-464e88875aee2328e04c3617bd9cc85f7b5389f117a38ea678b5d623a88fe998.json b/.sqlx/query-ac8c51d1316f7120d56b03c8881b997e8e55d73db7af45519acc176364feb4d4.json similarity index 89% rename from .sqlx/query-464e88875aee2328e04c3617bd9cc85f7b5389f117a38ea678b5d623a88fe998.json rename to .sqlx/query-ac8c51d1316f7120d56b03c8881b997e8e55d73db7af45519acc176364feb4d4.json index a8df23f..fa1baae 100644 --- a/.sqlx/query-464e88875aee2328e04c3617bd9cc85f7b5389f117a38ea678b5d623a88fe998.json +++ b/.sqlx/query-ac8c51d1316f7120d56b03c8881b997e8e55d73db7af45519acc176364feb4d4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, description, price, category as \"category: ExpenseCategory\", is_essential, date, uuid\n FROM expenses WHERE uuid = $1 AND user_id = $2", + "query": "SELECT id, description, price, category as \"category: ExpenseCategory\", is_essential, date, uuid\n FROM expenses\n WHERE uuid = $1 AND user_id = $2\n and deleted_at is null\n ", "describe": { "columns": [ { @@ -69,5 +69,5 @@ false ] }, - "hash": "464e88875aee2328e04c3617bd9cc85f7b5389f117a38ea678b5d623a88fe998" + "hash": "ac8c51d1316f7120d56b03c8881b997e8e55d73db7af45519acc176364feb4d4" } diff --git a/.sqlx/query-b05830f0e8a59fe8c1e566d368448d199a5c06b0b4845872d61ca9cb049a628c.json b/.sqlx/query-b05830f0e8a59fe8c1e566d368448d199a5c06b0b4845872d61ca9cb049a628c.json deleted file mode 100644 index 63d7302..0000000 --- a/.sqlx/query-b05830f0e8a59fe8c1e566d368448d199a5c06b0b4845872d61ca9cb049a628c.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM expenses\n WHERE uuid = $1 AND user_id = $2\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Int4" - ] - }, - "nullable": [] - }, - "hash": "b05830f0e8a59fe8c1e566d368448d199a5c06b0b4845872d61ca9cb049a628c" -} diff --git a/.sqlx/query-de408d7096db52682e68d9db5268e901782a70966a7a9a8049fbd05e4970114f.json b/.sqlx/query-de408d7096db52682e68d9db5268e901782a70966a7a9a8049fbd05e4970114f.json new file mode 100644 index 0000000..00712c4 --- /dev/null +++ b/.sqlx/query-de408d7096db52682e68d9db5268e901782a70966a7a9a8049fbd05e4970114f.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO expenses (description, price, category, is_essential, date, uuid, user_id)\n VALUES ($1, $2, $3 :: expense_category, $4, $5, $6, $7)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Float4", + { + "Custom": { + "name": "expense_category", + "kind": { + "Enum": [ + "food", + "transport", + "health", + "education", + "entertainment", + "other" + ] + } + } + }, + "Bool", + "Date", + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "de408d7096db52682e68d9db5268e901782a70966a7a9a8049fbd05e4970114f" +} diff --git a/.sqlx/query-e83fbc0ea18db1e75d6621993f1950026e9dfba6bd19ba001a03975c13024117.json b/.sqlx/query-e83fbc0ea18db1e75d6621993f1950026e9dfba6bd19ba001a03975c13024117.json new file mode 100644 index 0000000..ed8ad12 --- /dev/null +++ b/.sqlx/query-e83fbc0ea18db1e75d6621993f1950026e9dfba6bd19ba001a03975c13024117.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n update expenses\n set deleted_at = $1\n WHERE uuid = $2 AND user_id = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz", + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "e83fbc0ea18db1e75d6621993f1950026e9dfba6bd19ba001a03975c13024117" +} diff --git a/.sqlx/query-f6d77f09cd2971061abca5e008869fc2c7fbf826765ab9f803fed0f954776868.json b/.sqlx/query-f6d77f09cd2971061abca5e008869fc2c7fbf826765ab9f803fed0f954776868.json new file mode 100644 index 0000000..a21bca3 --- /dev/null +++ b/.sqlx/query-f6d77f09cd2971061abca5e008869fc2c7fbf826765ab9f803fed0f954776868.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select exists(select 1 from expenses where uuid = $1 AND user_id = $2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Int4" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f6d77f09cd2971061abca5e008869fc2c7fbf826765ab9f803fed0f954776868" +} From 75d800e3142b717618a52954808264d61a7a7bc5 Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 13:46:19 -0300 Subject: [PATCH 08/18] bump: flake and typos --- .pre-commit-config.yaml | 2 +- flake.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 05cf838..aefac77 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: - id: trailing-whitespace - id: detect-private-key - repo: https://github.com/crate-ci/typos - rev: v1.24.5 + rev: v1.24.6 hooks: - id: typos - repo: local diff --git a/flake.lock b/flake.lock index 47c268c..900b12a 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1726937504, - "narHash": "sha256-bvGoiQBvponpZh8ClUcmJ6QnsNKw0EMrCQJARK3bI1c=", + "lastModified": 1727348695, + "narHash": "sha256-J+PeFKSDV+pHL7ukkfpVzCOO7mBSrrpJ3svwBFABbhI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9357f4f23713673f310988025d9dc261c20e70c6", + "rev": "1925c603f17fc89f4c8f6bf6f631a802ad85d784", "type": "github" }, "original": { @@ -62,11 +62,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1727317727, - "narHash": "sha256-yGYahXzCquyYEgf5GTtvtaN5hXbw20Ok2+o8uVxoaFs=", + "lastModified": 1727577080, + "narHash": "sha256-2LPT76Acp6ebt7fCt90eq/M8T2+X09s/yTVgfVFrtno=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "a3d832f389606d7dc61a45b244c72ea472d1fcd4", + "rev": "73a833855442ce8cee710cf4d8d054fea1c81196", "type": "github" }, "original": { From 602185f3605b5294285df4433d7086a7c665d04f Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 20:03:18 -0300 Subject: [PATCH 09/18] chore: remove unused struct from schema --- src/schema.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/schema.rs b/src/schema.rs index 9d98d15..d10e11a 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -5,9 +5,7 @@ use std::{ use chrono::NaiveDate; use serde::{de, Deserialize, Serialize}; -use sqlx::FromRow; use strum::EnumIter; -use uuid::Uuid; #[derive(Debug, PartialEq, Eq, Serialize, Clone, EnumIter, Deserialize, sqlx::Type, Default)] #[sqlx(type_name = "expense_category", rename_all = "lowercase")] @@ -51,18 +49,6 @@ impl Display for ExpenseCategory { } } -#[derive(FromRow, Serialize, Debug, Default)] -/// Expense is a struct with the fields of an expense. -pub struct Expense { - pub id: i32, - pub description: String, - pub price: f32, - pub category: ExpenseCategory, - pub is_essential: bool, - pub date: NaiveDate, - pub uuid: Uuid, -} - #[derive(Deserialize, Debug)] /// `GetExpense` is a struct with the fields of an expense that can be retrieved. pub struct GetExpense { From 8ce9e9009b72e375e068c37551317f3f761af99d Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 20:04:02 -0300 Subject: [PATCH 10/18] feat: started upgrading user features to new architecture --- src/features/mod.rs | 1 + src/features/user.rs | 92 ++++++++++++++++++++++++++++++++++++++++++++ src/queries/mod.rs | 1 + src/queries/user.rs | 45 ++++++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 src/features/user.rs create mode 100644 src/queries/user.rs diff --git a/src/features/mod.rs b/src/features/mod.rs index 4b9bbab..e3e0333 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -1,3 +1,4 @@ pub mod expenses; pub mod openfinance; pub mod totp; +pub mod user; diff --git a/src/features/user.rs b/src/features/user.rs new file mode 100644 index 0000000..0155402 --- /dev/null +++ b/src/features/user.rs @@ -0,0 +1,92 @@ +use anyhow::bail; +use password_auth::generate_hash; +use sqlx::PgPool; +use validator::{Validate, ValidationErrors}; + +use crate::{ + client::{ + frc::{validate_frc, verify_frc_solution, FrcVerificationErrorList}, + mail::{send_sign_up_confirmation_mail, EmailSecrets}, + }, + hypermedia::schema::validation::SignUpInput, + util::{generate_verification_token, now_plus_24_hours}, + Secrets, +}; + +pub enum CreateOutcome { + PendingCaptcha, + InvalidCaptcha(FrcVerificationErrorList), + InvalidInput(ValidationErrors), + EmailTaken, + Success, +} + +pub async fn create( + db_pool: PgPool, + secrets: &Secrets, + create_user: SignUpInput, +) -> anyhow::Result { + if !validate_frc(&create_user.frc_captcha_solution) { + return Ok(CreateOutcome::PendingCaptcha); + } + + if let Err(e) = verify_frc_solution( + &create_user.frc_captcha_solution, + &secrets.frc_sitekey, + &secrets.frc_apikey, + ) + .await + { + return Ok(CreateOutcome::InvalidCaptcha(e)); + } + + if let Err(e) = create_user.validate() { + return Ok(CreateOutcome::InvalidInput(e)); + } + + if let Some(exists) = crate::queries::user::is_email_taken(&db_pool, &create_user.email).await? + { + if exists { + tracing::info!("entered via Some true"); + return Ok(CreateOutcome::EmailTaken); + } + } + + let Some(expiration_date) = now_plus_24_hours() else { + bail!("error adding 24 hours to current time"); + }; + let verification_token = generate_verification_token(); + let create_user_params = crate::queries::user::CreateParams { + name: &create_user.username, + email: &create_user.email, + hashed_pass: &generate_hash(&create_user.password), + verification_token: &verification_token, + expiration_date, + }; + + // TODO: change this to not use a transaction, and instead insert the table without depending + // on the emails being sent + // create a email_sent_at kind of column, with a task that attempts to send it + let mut transaction = db_pool.begin().await?; + crate::queries::user::create(&mut *transaction, create_user_params) + .await + .map(|c| { + if c.rows_affected() > 1 { + tracing::error!("i really need a macro that cancels the transaction"); + } + })?; + + send_sign_up_confirmation_mail( + &EmailSecrets { + smtp_username: &secrets.smtp_username, + smtp_host: &secrets.smtp_host, + smtp_key: &secrets.smtp_key, + mail_from: &secrets.mail_from, + }, + &create_user.email, + &verification_token, + )?; + + transaction.commit().await.unwrap(); + Ok(CreateOutcome::Success) +} diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 2624880..91d4d4b 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -1 +1,2 @@ pub mod expenses; +pub mod user; diff --git a/src/queries/user.rs b/src/queries/user.rs new file mode 100644 index 0000000..9b636db --- /dev/null +++ b/src/queries/user.rs @@ -0,0 +1,45 @@ +use chrono::{DateTime, Utc}; +use sqlx::PgExecutor; + +pub struct CreateParams<'a> { + pub name: &'a str, + pub email: &'a str, + pub hashed_pass: &'a str, + pub verification_token: &'a str, + pub expiration_date: DateTime, +} + +pub async fn create( + conn: impl PgExecutor<'_>, + p: CreateParams<'_>, +) -> Result { + sqlx::query!( + r#" + INSERT INTO users (username, email, password, verification_code, code_expires_at) + VALUES ($1, $2, $3, $4, $5) + "#, + p.name, + p.email, + p.hashed_pass, + p.verification_token, + p.expiration_date + ) + .execute(conn) + .await +} + +pub async fn is_email_taken( + conn: impl PgExecutor<'_>, + email: &str, +) -> Result, sqlx::Error> { + let record = sqlx::query!( + r#" + select exists (select 1 from users where email = $1) + "#, + email + ) + .fetch_one(conn) + .await?; + + Ok(record.exists) +} From 3c3cb2eaf4da4b03ae183bd5ce017b7ed76c6e26 Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 20:05:07 -0300 Subject: [PATCH 11/18] fix: inserting created_at on expense creation --- src/features/expenses.rs | 3 ++- src/queries/expenses.rs | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/features/expenses.rs b/src/features/expenses.rs index 776e5b1..b4d3f8e 100644 --- a/src/features/expenses.rs +++ b/src/features/expenses.rs @@ -1,4 +1,4 @@ -use chrono::NaiveDate; +use chrono::{NaiveDate, Utc}; use plotly::{Layout, Plot, Scatter}; use sqlx::{Pool, Postgres}; use std::collections::BTreeMap; @@ -17,6 +17,7 @@ pub async fn create( category: create_expense.category, is_essential: create_expense.is_essential, date: create_expense.date, + now: Utc::now(), }; crate::queries::expenses::create(&db_pool, user_id, params) .await diff --git a/src/queries/expenses.rs b/src/queries/expenses.rs index f69085f..fe4d2ca 100644 --- a/src/queries/expenses.rs +++ b/src/queries/expenses.rs @@ -11,6 +11,7 @@ pub struct CreateParams { pub category: ExpenseCategory, pub is_essential: bool, pub date: NaiveDate, + pub now: DateTime, } pub async fn create( @@ -20,8 +21,8 @@ pub async fn create( ) -> Result { sqlx::query!( r#" - INSERT INTO expenses (description, price, category, is_essential, date, uuid, user_id) - VALUES ($1, $2, $3 :: expense_category, $4, $5, $6, $7) + INSERT INTO expenses (description, price, category, is_essential, date, uuid, user_id, created_at) + VALUES ($1, $2, $3 :: expense_category, $4, $5, $6, $7, $8) "#, p.description, p.price, @@ -29,7 +30,8 @@ pub async fn create( p.is_essential, p.date, Uuid::new_v4(), - user_id + user_id, + p.now, ) .execute(db_pool) .await From a4939d475aafa8b7c69a14cb08c5099df4281561 Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 20:05:32 -0300 Subject: [PATCH 12/18] feat: using pgpool alias on expenses queries feat: experimenting with passing things that implement pgexecutor to queries to support both connections and transactions --- src/features/expenses.rs | 16 ++++++++-------- src/queries/expenses.rs | 20 ++++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/features/expenses.rs b/src/features/expenses.rs index b4d3f8e..69b0431 100644 --- a/src/features/expenses.rs +++ b/src/features/expenses.rs @@ -1,6 +1,6 @@ use chrono::{NaiveDate, Utc}; use plotly::{Layout, Plot, Scatter}; -use sqlx::{Pool, Postgres}; +use sqlx::PgPool; use std::collections::BTreeMap; use uuid::Uuid; @@ -8,7 +8,7 @@ use crate::schema::{CreateExpense, UpdateExpenseApi, UpdateExpenseHypr}; pub async fn create( user_id: i32, - db_pool: Pool, + db_pool: PgPool, create_expense: CreateExpense, ) -> Result<(), sqlx::Error> { let params = crate::queries::expenses::CreateParams { @@ -31,7 +31,7 @@ pub async fn create( pub async fn list_in_period( user_id: i32, - db_pool: Pool, + db_pool: PgPool, from: Option, to: Option, ) -> Result, sqlx::Error> { @@ -40,7 +40,7 @@ pub async fn list_in_period( pub async fn find_active_for_user( user_id: i32, - db_pool: Pool, + db_pool: PgPool, expense_uuid: Uuid, ) -> Result { crate::queries::expenses::find_active_for_user(&db_pool, user_id, expense_uuid).await @@ -48,7 +48,7 @@ pub async fn find_active_for_user( pub async fn update( user_id: i32, - db_pool: Pool, + db_pool: PgPool, expense_uuid: Uuid, update: UpdateExpenseApi, ) -> Result<(), sqlx::Error> { @@ -76,7 +76,7 @@ pub enum UpdateOutcome { pub async fn update_for_site( user_id: i32, - db_pool: Pool, + db_pool: PgPool, expense_uuid: Uuid, update: UpdateExpenseHypr, ) -> anyhow::Result { @@ -112,7 +112,7 @@ pub enum DeleteOutcome { pub async fn delete( user_id: i32, - db_pool: Pool, + db_pool: PgPool, expense_uuid: Uuid, ) -> anyhow::Result { if let Some(exists) = @@ -140,7 +140,7 @@ pub async fn delete( pub async fn plot( user_id: i32, - db_pool: Pool, + db_pool: PgPool, from: Option, to: Option, ) -> Result { diff --git a/src/queries/expenses.rs b/src/queries/expenses.rs index fe4d2ca..35e7a10 100644 --- a/src/queries/expenses.rs +++ b/src/queries/expenses.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, NaiveDate, Utc}; use serde::Serialize; -use sqlx::{Pool, Postgres}; +use sqlx::{PgExecutor, PgPool}; use uuid::Uuid; use crate::schema::ExpenseCategory; @@ -15,7 +15,7 @@ pub struct CreateParams { } pub async fn create( - db_pool: &Pool, + conn: impl PgExecutor<'_>, user_id: i32, p: CreateParams, ) -> Result { @@ -33,7 +33,7 @@ pub async fn create( user_id, p.now, ) - .execute(db_pool) + .execute(conn) .await } @@ -50,7 +50,7 @@ pub struct Expense { } pub async fn list_for_user_in_period( - db_pool: &Pool, + conn: impl PgExecutor<'_>, user_id: i32, from: Option, to: Option, @@ -68,12 +68,12 @@ pub async fn list_for_user_in_period( to, user_id ) - .fetch_all(db_pool) + .fetch_all(conn) .await } pub async fn find_active_for_user( - db_pool: &Pool, + db_pool: &PgPool, user_id: i32, expense_uuid: Uuid, ) -> Result { @@ -92,7 +92,7 @@ pub async fn find_active_for_user( } pub async fn exists_active( - db_pool: &Pool, + db_pool: &PgPool, user_id: i32, expense_uuid: Uuid, ) -> Result, sqlx::Error> { @@ -118,7 +118,7 @@ pub struct UpdateParams { } pub async fn update( - db_pool: &Pool, + db_pool: &PgPool, user_id: i32, expense_uuid: Uuid, p: UpdateParams, @@ -147,7 +147,7 @@ pub async fn update( } pub async fn update_for_site( - db_pool: &Pool, + db_pool: &PgPool, user_id: i32, expense_uuid: Uuid, p: UpdateParams, @@ -178,7 +178,7 @@ pub async fn update_for_site( } pub async fn delete( - db_pool: &Pool, + db_pool: &PgPool, user_id: i32, expense_uuid: Uuid, now: DateTime, From e0329c38a3243c79eb3222005cd796115704b76d Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 20:06:56 -0300 Subject: [PATCH 13/18] test: we are now able to test expenses features --- Cargo.toml | 2 +- src/features/expenses.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 30da1c2..8b1990b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ shuttle-axum = { version = "0.47.0", default-features = false, features = ["axum shuttle-common = { version = "0.47.0", default-features = false } shuttle-runtime = { version = "0.47.0", default-features = false, features = ["colored"] } shuttle-shared-db = { version = "0.47.0", default-features = false, features = ["postgres", "sqlx"] } -sqlx = { version = "0.7.4", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] } +sqlx = { version = "0.7.4", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] } strum = { version = "0.26.3", default-features = false, features = ["strum_macros", "derive"] } svix = { version = "1.36.0", default-features = false, features = ["native-tls"] } time = { version = "0.3.36", default-features = false } diff --git a/src/features/expenses.rs b/src/features/expenses.rs index 69b0431..953afd8 100644 --- a/src/features/expenses.rs +++ b/src/features/expenses.rs @@ -167,3 +167,43 @@ pub async fn plot( Ok(plot) } + +#[cfg(test)] +mod tests { + use chrono::Utc; + use sqlx::PgPool; + + #[sqlx::test] + async fn create_success(pool: PgPool) { + let email = "test@test.com"; + let now = Utc::now(); + crate::queries::user::create( + &pool, + crate::queries::user::CreateParams { + name: "test_name", + email, + hashed_pass: "test_hash", + verification_token: "test_token", + expiration_date: now, + }, + ) + .await + .unwrap(); + + let user_id = sqlx::query!("SELECT id FROM users WHERE email = $1", email) + .fetch_one(&pool) + .await + .unwrap() + .id; + + let create_expense = crate::schema::CreateExpense { + description: "test_expense".to_owned(), + price: 6.9, + category: crate::schema::ExpenseCategory::Food, + is_essential: false, + date: now.date_naive(), + }; + + super::create(user_id, pool, create_expense).await.unwrap(); + } +} From 5278b0225f0bcb38ed1b199949a43a9db41822c4 Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 20:07:10 -0300 Subject: [PATCH 14/18] chore: adding todo to mailer to become part of app state --- src/client/mail.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/mail.rs b/src/client/mail.rs index 5c3631c..0bd616b 100644 --- a/src/client/mail.rs +++ b/src/client/mail.rs @@ -10,6 +10,8 @@ pub struct EmailSecrets<'a> { pub mail_from: &'a str, } +// TODO: create a mailer object and add it to application state +// instead of creating on every call pub fn send_email( email_secrets: &EmailSecrets, to_email: &str, From 59be676e27fc7b564f814f641cc79f9bd47d9c14 Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 23:03:42 -0300 Subject: [PATCH 15/18] feat: migrate create user to new architecture todo: - check forgot password - check change password - check confirm email and basically the whole auth flow --- src/features/user.rs | 64 ++++++++++------- src/hypermedia/router/auth.rs | 42 ++++++++++- src/hypermedia/service/auth.rs | 125 +-------------------------------- src/queries/user.rs | 39 +++++++--- 4 files changed, 111 insertions(+), 159 deletions(-) diff --git a/src/features/user.rs b/src/features/user.rs index 0155402..bd548f3 100644 --- a/src/features/user.rs +++ b/src/features/user.rs @@ -6,7 +6,7 @@ use validator::{Validate, ValidationErrors}; use crate::{ client::{ frc::{validate_frc, verify_frc_solution, FrcVerificationErrorList}, - mail::{send_sign_up_confirmation_mail, EmailSecrets}, + mail::{send_forgot_password_mail, send_sign_up_confirmation_mail, EmailSecrets}, }, hypermedia::schema::validation::SignUpInput, util::{generate_verification_token, now_plus_24_hours}, @@ -17,7 +17,8 @@ pub enum CreateOutcome { PendingCaptcha, InvalidCaptcha(FrcVerificationErrorList), InvalidInput(ValidationErrors), - EmailTaken, + EmailAlreadyConfirmed, + EmailConfirmationResent, Success, } @@ -44,18 +45,46 @@ pub async fn create( return Ok(CreateOutcome::InvalidInput(e)); } - if let Some(exists) = crate::queries::user::is_email_taken(&db_pool, &create_user.email).await? - { - if exists { - tracing::info!("entered via Some true"); - return Ok(CreateOutcome::EmailTaken); - } - } + let email_secrets = EmailSecrets { + smtp_username: &secrets.smtp_username, + smtp_host: &secrets.smtp_host, + smtp_key: &secrets.smtp_key, + mail_from: &secrets.mail_from, + }; let Some(expiration_date) = now_plus_24_hours() else { bail!("error adding 24 hours to current time"); }; let verification_token = generate_verification_token(); + + // TODO: change this to not use a transaction, and instead insert the table without depending + // on the emails being sent + // create a email_sent_at kind of column, with a task that attempts to send it + let mut transaction = db_pool.begin().await?; + + if let Some(user) = + crate::queries::user::user_state_for_signup(&db_pool, &create_user.email).await? + { + let verification_token = generate_verification_token(); + crate::queries::user::set_email_prereq( + &mut *transaction, + &verification_token, + expiration_date, + user.id, + ) + .await?; + + if user.verified { + send_forgot_password_mail(&email_secrets, &create_user.email, &verification_token)?; + transaction.commit().await?; + return Ok(CreateOutcome::EmailAlreadyConfirmed); + } + + send_sign_up_confirmation_mail(&email_secrets, &create_user.email, &verification_token)?; + transaction.commit().await?; + return Ok(CreateOutcome::EmailConfirmationResent); + } + let create_user_params = crate::queries::user::CreateParams { name: &create_user.username, email: &create_user.email, @@ -64,10 +93,6 @@ pub async fn create( expiration_date, }; - // TODO: change this to not use a transaction, and instead insert the table without depending - // on the emails being sent - // create a email_sent_at kind of column, with a task that attempts to send it - let mut transaction = db_pool.begin().await?; crate::queries::user::create(&mut *transaction, create_user_params) .await .map(|c| { @@ -76,17 +101,8 @@ pub async fn create( } })?; - send_sign_up_confirmation_mail( - &EmailSecrets { - smtp_username: &secrets.smtp_username, - smtp_host: &secrets.smtp_host, - smtp_key: &secrets.smtp_key, - mail_from: &secrets.mail_from, - }, - &create_user.email, - &verification_token, - )?; + send_sign_up_confirmation_mail(&email_secrets, &create_user.email, &verification_token)?; - transaction.commit().await.unwrap(); + transaction.commit().await?; Ok(CreateOutcome::Success) } diff --git a/src/hypermedia/router/auth.rs b/src/hypermedia/router/auth.rs index 3a6406d..f1d8202 100644 --- a/src/hypermedia/router/auth.rs +++ b/src/hypermedia/router/auth.rs @@ -12,9 +12,11 @@ use std::sync::Arc; use askama_axum::IntoResponse; use axum::{ extract::{Path, State}, + response::{Html, Response}, routing::{get, post}, Form, Router, }; +use reqwest::StatusCode; pub fn public_router() -> Router> { Router::new() @@ -98,13 +100,47 @@ async fn signup_tab(State(shared_state): State>) -> impl IntoRespo async fn signup( State(shared_state): State>, Form(signup_input): Form, -) -> impl IntoResponse { - crate::hypermedia::service::auth::signup( - &shared_state.pool, +) -> Result { + let outcome = crate::features::user::create( + shared_state.pool.clone(), &shared_state.secrets, signup_input, ) .await + .map_err(|e| { + tracing::error!("Error inserting expense: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + })?; + + match outcome { + crate::features::user::CreateOutcome::PendingCaptcha => Err(( + StatusCode::BAD_REQUEST, + Html("

Please complete the captcha

"), + ) + .into_response()), + crate::features::user::CreateOutcome::InvalidCaptcha(e) => { + tracing::error!("Error verifying frc solution: {}", e); + Err(( + StatusCode::FAILED_DEPENDENCY, + Html(format!( + "

Error verifying captcha {e}

" + )), + ) + .into_response()) + } + crate::features::user::CreateOutcome::InvalidInput(e) => Err(( + StatusCode::PRECONDITION_FAILED, + Html(format!( + "

Please send valid inputs {e}

" + )), + ) + .into_response()), + crate::features::user::CreateOutcome::Success + | crate::features::user::CreateOutcome::EmailConfirmationResent + | crate::features::user::CreateOutcome::EmailAlreadyConfirmed => { + Ok([("HX-Redirect", "/auth/email-confirmation")]) + } + } } async fn resend_verification_email( diff --git a/src/hypermedia/service/auth.rs b/src/hypermedia/service/auth.rs index da05a7c..11c3cfd 100644 --- a/src/hypermedia/service/auth.rs +++ b/src/hypermedia/service/auth.rs @@ -8,7 +8,7 @@ use crate::{ features::totp::set_otp_secret, hypermedia::schema::{ auth::MailToUser, - validation::{ChangePasswordInput, Exists, ForgotPasswordInput, ResendEmail, SignUpInput}, + validation::{ChangePasswordInput, Exists, ForgotPasswordInput, ResendEmail}, }, templates::{ ChangePasswordTemplate, ConfirmationTemplate, ForgotPasswordTemplate, MfaTemplate, @@ -218,129 +218,6 @@ pub fn signup_tab(secrets: &Secrets) -> impl IntoResponse { .into_response_with_nonce(); } -#[expect(clippy::too_many_lines, reason = "not yet reviewed this function")] -pub async fn signup( - db_pool: &Pool, - secrets: &Secrets, - signup_input: SignUpInput, -) -> impl IntoResponse { - if !validate_frc(&signup_input.frc_captcha_solution) { - return ( - StatusCode::BAD_REQUEST, - Html("

Please complete the captcha

"), - ) - .into_response(); - } - if let Err(e) = verify_frc_solution( - &signup_input.frc_captcha_solution, - &secrets.frc_sitekey, - &secrets.frc_apikey, - ) - .await - { - tracing::error!("Error verifying frc solution: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Html("

Error verifying captcha

"), - ) - .into_response(); - } - - if let Err(e) = signup_input.validate() { - tracing::error!("Error validating signup input: {}", e); - return StatusCode::BAD_REQUEST.into_response(); - } - - let email_secrets = crate::client::mail::EmailSecrets { - smtp_username: &secrets.smtp_username, - smtp_host: &secrets.smtp_host, - smtp_key: &secrets.smtp_key, - mail_from: &secrets.mail_from, - }; - - if let Ok(record) = sqlx::query_as!( - Exists, - r#"SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)"#, - signup_input.email - ) - .fetch_one(db_pool) - .await - { - if record.exists.unwrap() { - match send_forgot_password_mail( - &email_secrets, - &signup_input.email, - &signup_input.username, - ) { - Ok(_) => { - return ( - StatusCode::OK, - [("HX-Redirect", "/auth/email-confirmation")], - ) - .into_response() - } - Err(e) => { - tracing::error!("Error sending forgot password mail: {}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - } - } - } else { - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - - let hashed_pass = generate_hash(&signup_input.password); - let mut transaction = db_pool.begin().await.unwrap(); - - let Some(expiration_date) = now_plus_24_hours() else { - transaction.rollback().await.unwrap(); - tracing::error!("Error generating expiration date, maybe it overflowed?"); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - }; - - match sqlx::query!( - r#" - INSERT INTO users (username, email, password, verification_code, code_expires_at) - VALUES ($1, $2, $3, $4, $5) - RETURNING email, verification_code - "#, - signup_input.username, - signup_input.email, - hashed_pass, - generate_verification_token(), - expiration_date - ) - .fetch_one(&mut *transaction) - .await - { - Ok(mail_to_user) => { - match send_sign_up_confirmation_mail( - &email_secrets, - &mail_to_user.email, - &mail_to_user.verification_code.unwrap(), - ) { - Ok(_) => { - transaction.commit().await.unwrap(); - ( - StatusCode::OK, - [("HX-Redirect", "/auth/email-confirmation")], - ) - .into_response() - } - Err(e) => { - transaction.rollback().await.unwrap(); - tracing::error!("Error sending sign up confirmation mail: {}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } - } - Err(e) => { - tracing::error!("Error signing up: {}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } -} - pub fn email_confirmation(secrets: &Secrets) -> impl IntoResponse { return ConfirmationTemplate { frc_sitekey: secrets.frc_sitekey.clone(), diff --git a/src/queries/user.rs b/src/queries/user.rs index 9b636db..6299545 100644 --- a/src/queries/user.rs +++ b/src/queries/user.rs @@ -28,18 +28,41 @@ pub async fn create( .await } -pub async fn is_email_taken( +pub struct ForSignup { + pub id: i32, + pub verified: bool, +} + +pub async fn user_state_for_signup( conn: impl PgExecutor<'_>, email: &str, -) -> Result, sqlx::Error> { - let record = sqlx::query!( +) -> Result, sqlx::Error> { + sqlx::query_as!( + ForSignup, r#" - select exists (select 1 from users where email = $1) - "#, + select id, verified from users where email = $1 + "#, email ) - .fetch_one(conn) - .await?; + .fetch_optional(conn) + .await +} - Ok(record.exists) +pub async fn set_email_prereq( + conn: impl PgExecutor<'_>, + verification_code: &str, + expires_at: DateTime, + user_id: i32, +) -> Result { + sqlx::query!( + r#" + update users set verification_code = $1, code_expires_at = $2 + where id = $3 + "#, + verification_code, + expires_at, + user_id + ) + .execute(conn) + .await } From e23d08eb9b5568bb5e48e908a17e7466257e7931 Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 23:05:55 -0300 Subject: [PATCH 16/18] sqlx: cargo sqlx prepare --- ...70b3360a3ac71e649b293efb88d92c3254068.json | 22 +++++++++++++ ...6e0b56f8059e87ae93fe9412feefd2d7acea9.json | 18 +++++++++++ ...12cf71c6ce960427b3f074fef315255be7f8.json} | 7 ++-- ...b6f2e2937a4f0a1342e507e709b128034dfe7.json | 16 ++++++++++ ...1dab6e6109d77bd35d42d8955063b2fa90a0d.json | 32 ------------------- ...e209a83e62a2a47365186379b7ae332df1288.json | 28 ++++++++++++++++ 6 files changed, 88 insertions(+), 35 deletions(-) create mode 100644 .sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json create mode 100644 .sqlx/query-4ef2a3f49039f539c459c4da3686e0b56f8059e87ae93fe9412feefd2d7acea9.json rename .sqlx/{query-de408d7096db52682e68d9db5268e901782a70966a7a9a8049fbd05e4970114f.json => query-5cc722f72ce0a07b1f67b3cdc78712cf71c6ce960427b3f074fef315255be7f8.json} (71%) create mode 100644 .sqlx/query-6297d0c49aa952a88b018cb9b73b6f2e2937a4f0a1342e507e709b128034dfe7.json delete mode 100644 .sqlx/query-648ab115381b3a0346518ab5b951dab6e6109d77bd35d42d8955063b2fa90a0d.json create mode 100644 .sqlx/query-cf61a6fa854221c0816f86384ece209a83e62a2a47365186379b7ae332df1288.json diff --git a/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json b/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json new file mode 100644 index 0000000..f464c54 --- /dev/null +++ b/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068" +} diff --git a/.sqlx/query-4ef2a3f49039f539c459c4da3686e0b56f8059e87ae93fe9412feefd2d7acea9.json b/.sqlx/query-4ef2a3f49039f539c459c4da3686e0b56f8059e87ae93fe9412feefd2d7acea9.json new file mode 100644 index 0000000..f6b81f9 --- /dev/null +++ b/.sqlx/query-4ef2a3f49039f539c459c4da3686e0b56f8059e87ae93fe9412feefd2d7acea9.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users (username, email, password, verification_code, code_expires_at)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Varchar", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "4ef2a3f49039f539c459c4da3686e0b56f8059e87ae93fe9412feefd2d7acea9" +} diff --git a/.sqlx/query-de408d7096db52682e68d9db5268e901782a70966a7a9a8049fbd05e4970114f.json b/.sqlx/query-5cc722f72ce0a07b1f67b3cdc78712cf71c6ce960427b3f074fef315255be7f8.json similarity index 71% rename from .sqlx/query-de408d7096db52682e68d9db5268e901782a70966a7a9a8049fbd05e4970114f.json rename to .sqlx/query-5cc722f72ce0a07b1f67b3cdc78712cf71c6ce960427b3f074fef315255be7f8.json index 00712c4..9ca2a79 100644 --- a/.sqlx/query-de408d7096db52682e68d9db5268e901782a70966a7a9a8049fbd05e4970114f.json +++ b/.sqlx/query-5cc722f72ce0a07b1f67b3cdc78712cf71c6ce960427b3f074fef315255be7f8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO expenses (description, price, category, is_essential, date, uuid, user_id)\n VALUES ($1, $2, $3 :: expense_category, $4, $5, $6, $7)\n ", + "query": "\n INSERT INTO expenses (description, price, category, is_essential, date, uuid, user_id, created_at)\n VALUES ($1, $2, $3 :: expense_category, $4, $5, $6, $7, $8)\n ", "describe": { "columns": [], "parameters": { @@ -25,10 +25,11 @@ "Bool", "Date", "Uuid", - "Int4" + "Int4", + "Timestamptz" ] }, "nullable": [] }, - "hash": "de408d7096db52682e68d9db5268e901782a70966a7a9a8049fbd05e4970114f" + "hash": "5cc722f72ce0a07b1f67b3cdc78712cf71c6ce960427b3f074fef315255be7f8" } diff --git a/.sqlx/query-6297d0c49aa952a88b018cb9b73b6f2e2937a4f0a1342e507e709b128034dfe7.json b/.sqlx/query-6297d0c49aa952a88b018cb9b73b6f2e2937a4f0a1342e507e709b128034dfe7.json new file mode 100644 index 0000000..97bce37 --- /dev/null +++ b/.sqlx/query-6297d0c49aa952a88b018cb9b73b6f2e2937a4f0a1342e507e709b128034dfe7.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n update users set verification_code = $1, code_expires_at = $2\n where id = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Timestamptz", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "6297d0c49aa952a88b018cb9b73b6f2e2937a4f0a1342e507e709b128034dfe7" +} diff --git a/.sqlx/query-648ab115381b3a0346518ab5b951dab6e6109d77bd35d42d8955063b2fa90a0d.json b/.sqlx/query-648ab115381b3a0346518ab5b951dab6e6109d77bd35d42d8955063b2fa90a0d.json deleted file mode 100644 index e190f2f..0000000 --- a/.sqlx/query-648ab115381b3a0346518ab5b951dab6e6109d77bd35d42d8955063b2fa90a0d.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO users (username, email, password, verification_code, code_expires_at)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING email, verification_code\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "verification_code", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Varchar", - "Timestamptz" - ] - }, - "nullable": [ - false, - true - ] - }, - "hash": "648ab115381b3a0346518ab5b951dab6e6109d77bd35d42d8955063b2fa90a0d" -} diff --git a/.sqlx/query-cf61a6fa854221c0816f86384ece209a83e62a2a47365186379b7ae332df1288.json b/.sqlx/query-cf61a6fa854221c0816f86384ece209a83e62a2a47365186379b7ae332df1288.json new file mode 100644 index 0000000..77c9a47 --- /dev/null +++ b/.sqlx/query-cf61a6fa854221c0816f86384ece209a83e62a2a47365186379b7ae332df1288.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select id, verified from users where email = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "verified", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "cf61a6fa854221c0816f86384ece209a83e62a2a47365186379b7ae332df1288" +} From bed3e9b1d09f54d6f1feffcae12682cccf522d68 Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Sun, 29 Sep 2024 23:24:51 -0300 Subject: [PATCH 17/18] ci: configuring database for tests --- .circleci/config.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5b9427a..02e2317 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,11 +12,14 @@ jobs: build-and-test: docker: - image: cimg/rust:1.81.0 - environment: - # Fail the build if there are warnings - RUSTFLAGS: "-D warnings" - CARGO_INCREMENTAL: 0 - # RUSTC_WRAPPER: "sccache" + environment: + # Fail the build if there are warnings + RUSTFLAGS: "-D warnings" + CARGO_INCREMENTAL: 0 + # RUSTC_WRAPPER: "sccache" + - image: cimg/postgres:17.0 + environment: + POSTGRES_USER: postgres steps: - checkout - run: @@ -52,6 +55,8 @@ jobs: name: Run tests command: | cargo nextest run --no-fail-fast --archive-file nextest-archive.tar.zst + environment: + DATABASE_URL: postgresql://postgres@localhost/circle_test security: docker: @@ -162,6 +167,8 @@ jobs: name: Running code coverage command: | rustup run nightly cargo llvm-cov nextest --locked --all-features --lcov --output-path lcov.info + environment: + DATABASE_URL: postgresql://postgres@localhost/circle_test - codecov/upload: file: "./lcov.info" # token: $CODECOV_TOKEN already default From a1dfc53d0ad92fe4a6f7692ec904609be33c0426 Mon Sep 17 00:00:00 2001 From: Nicolas Auler Date: Mon, 30 Sep 2024 00:15:35 -0300 Subject: [PATCH 18/18] feat: support database tests in coverage --- .circleci/config.yml | 55 ++++++++++++++++++++++++++++++++++++-------- flake.nix | 1 + 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 02e2317..815be69 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -61,6 +61,11 @@ jobs: security: docker: - image: cimg/rust:1.81.0 + environment: + # Fail the build if there are warnings + RUSTFLAGS: "-D warnings" + CARGO_INCREMENTAL: 0 + # RUSTC_WRAPPER: "sccache" steps: - checkout - run: @@ -87,6 +92,11 @@ jobs: format: docker: - image: cimg/rust:1.81.0 + environment: + # Fail the build if there are warnings + RUSTFLAGS: "-D warnings" + CARGO_INCREMENTAL: 0 + # RUSTC_WRAPPER: "sccache" steps: - checkout - run: @@ -106,6 +116,11 @@ jobs: # See: https://circleci.com/docs/configuration-reference/#executor-job docker: - image: cimg/rust:1.81.0 + environment: + # Fail the build if there are warnings + RUSTFLAGS: "-D warnings" + CARGO_INCREMENTAL: 0 + # RUSTC_WRAPPER: "sccache" # Add steps to the job # See: https://circleci.com/docs/configuration-reference/#steps steps: @@ -124,6 +139,11 @@ jobs: doc: docker: - image: cimg/rust:1.81.0 + environment: + # Fail the build if there are warnings + RUSTFLAGS: "-D warnings" + CARGO_INCREMENTAL: 0 + # RUSTC_WRAPPER: "sccache" steps: - checkout - run: @@ -139,6 +159,16 @@ jobs: coverage: docker: - image: cimg/rust:1.81.0 + environment: + # Fail the build if there are warnings + RUSTFLAGS: "-D warnings" + CARGO_INCREMENTAL: 0 + # RUSTC_WRAPPER: "sccache" + - image: cimg/postgres:17.0 + environment: + POSTGRES_USER: postgres + environment: + DATABASE_URL: postgresql://postgres@localhost/circle_test steps: - checkout - run: @@ -158,6 +188,14 @@ jobs: name: Install nextest command: | curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C ${CARGO_HOME:-~/.cargo}/bin + - run: + name: Install sqlx-cli + command: cargo install sqlx-cli + - run: + name: Database setup + command: | + sqlx database create + sqlx migrate run - run: name: Setup test.env command: | @@ -167,8 +205,6 @@ jobs: name: Running code coverage command: | rustup run nightly cargo llvm-cov nextest --locked --all-features --lcov --output-path lcov.info - environment: - DATABASE_URL: postgresql://postgres@localhost/circle_test - codecov/upload: file: "./lcov.info" # token: $CODECOV_TOKEN already default @@ -231,10 +267,11 @@ workflows: filters: tags: only: /.*/ - - shuttle-deploy: - requires: - - doc - - coverage - filters: - branches: - only: main +# skipping auto deploy for now +# - shuttle-deploy: +# requires: +# - doc +# - coverage +# filters: +# branches: +# only: main diff --git a/flake.nix b/flake.nix index 2609633..b96db79 100644 --- a/flake.nix +++ b/flake.nix @@ -48,6 +48,7 @@ buildInputs = [ bacon cargo-expand + cargo-llvm-cov cargo-nextest cargo-watch jq