From cd013d0009ba860af003991a66bb311d9600e444 Mon Sep 17 00:00:00 2001 From: "Z. Charles Dziura" Date: Wed, 12 Mar 2025 16:53:44 -0400 Subject: [PATCH] Create budget creation request --- .../20231221181946_create-tables.down.sql | 13 ++-- .../20231221181946_create-tables.up.sql | 22 ++++++- api/src/db/budget.rs | 50 +++++++++++++++ api/src/db/mod.rs | 6 +- api/src/db/permission.rs | 1 + api/src/db/user_budget_permission.rs | 61 +++++++++++++++++++ api/src/requests/budget/create/handler.rs | 52 ++++++++++++++++ api/src/requests/budget/create/mod.rs | 4 ++ api/src/requests/budget/create/models/mod.rs | 5 ++ .../requests/budget/create/models/request.rs | 9 +++ .../requests/budget/create/models/response.rs | 40 ++++++++++++ api/src/requests/budget/mod.rs | 15 +++++ api/src/requests/mod.rs | 8 ++- 13 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 api/src/db/budget.rs create mode 100644 api/src/db/user_budget_permission.rs create mode 100644 api/src/requests/budget/create/handler.rs create mode 100644 api/src/requests/budget/create/mod.rs create mode 100644 api/src/requests/budget/create/models/mod.rs create mode 100644 api/src/requests/budget/create/models/request.rs create mode 100644 api/src/requests/budget/create/models/response.rs create mode 100644 api/src/requests/budget/mod.rs diff --git a/api/migrations/20231221181946_create-tables.down.sql b/api/migrations/20231221181946_create-tables.down.sql index 6de353b..c5f2df8 100644 --- a/api/migrations/20231221181946_create-tables.down.sql +++ b/api/migrations/20231221181946_create-tables.down.sql @@ -6,9 +6,12 @@ DROP INDEX IF EXISTS permission_category_idx; DROP INDEX IF EXISTS permission_category_name_idx; DROP INDEX IF EXISTS user_account_permission_user_id_idx; DROP INDEX IF EXISTS user_account_permission_account_id_idx; -DROP INDEX IF EXISTS transaction_budget_id_idx +DROP INDEX IF EXISTS transaction_budget_id_idx; +DROP INDEX IF EXISTS user_budget_permission_user_id_idx; +DROP INDEX IF EXISTS user_budget_permission_budget_id_idx; DROP TABLE IF EXISTS public.user_account_permission; +DROP TABLE IF EXISTS public.user_budget_permission; DROP TABLE IF EXISTS public.account; DROP TABLE IF EXISTS public.permission; DROP TABLE IF EXISTS public.user; @@ -16,7 +19,7 @@ DROP TABLE IF EXISTS public.transaction_line_item; DROP TABLE IF EXISTS public.transaction CASCADE; DROP TABLE IF EXISTS public.budget CASCADE; -DROP TYPE public.entry_type; -DROP TYPE public.account_type; -DROP TYPE public.permission_category; -DROP TYPE public.status; +DROP TYPE IF EXISTS public.entry_type; +DROP TYPE IF EXISTS public.account_type; +DROP TYPE IF EXISTS public.permission_category; +DROP TYPE IF EXISTS public.status; diff --git a/api/migrations/20231221181946_create-tables.up.sql b/api/migrations/20231221181946_create-tables.up.sql index 35a5af7..86d190e 100644 --- a/api/migrations/20231221181946_create-tables.up.sql +++ b/api/migrations/20231221181946_create-tables.up.sql @@ -32,7 +32,7 @@ AS ENUM CREATE TYPE public.permission_category AS ENUM - ('Account'); + ('Account', 'Budget'); CREATE TABLE IF NOT EXISTS public.user ( @@ -71,7 +71,11 @@ INSERT INTO ('Account', 'View', 1), ('Account', 'Edit', 2), ('Account', 'Delete', 4), - ('Account', 'Grant', 8); + ('Account', 'Grant', 8), + ('Budget', 'View', 1), + ('Budget', 'Edit', 2), + ('Budget', 'Delete', 4), + ('Budget', 'Grant', 8); CREATE TABLE IF NOT EXISTS public.account ( @@ -110,6 +114,20 @@ CREATE TABLE IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE NULL ); +CREATE TABLE IF NOT EXISTS + public.user_budget_permission ( + id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id INT NOT NULL REFERENCES public.user(id), + budget_id UUID NOT NULL REFERENCES public.budget(id), + permission_id INT NOT NULL REFERENCES public.permission(id), + status status NOT NULL DEFAULT 'Active', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NULL + ); + +CREATE INDEX IF NOT EXISTS user_budget_permission_user_id_idx ON public.user_budget_permission(user_id); +CREATE INDEX IF NOT EXISTS user_budget_permission_budget_id_idx ON public.user_budget_permission(budget_id); + CREATE TABLE IF NOT EXISTS public.transaction ( id UUID PRIMARY KEY DEFAULT uuidv7(), diff --git a/api/src/db/budget.rs b/api/src/db/budget.rs new file mode 100644 index 0000000..a846690 --- /dev/null +++ b/api/src/db/budget.rs @@ -0,0 +1,50 @@ +use sqlx::prelude::*; +use tracing::error; +use uuid::Uuid; + +use crate::models::AppError; + +use super::{DbPool, PermissionEntity, StatusType, associate_budget_with_user_as_owner}; + +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub struct NewBudgetEntity { + pub name: String, + pub description: Option, + pub icon: Option, +} + +#[allow(dead_code)] +#[derive(Debug, FromRow)] +pub struct BudgetEntity { + pub id: Uuid, + pub name: String, + pub description: Option, + pub icon: Option, + pub status: StatusType, +} + +pub async fn insert_new_budget_for_user( + pool: &DbPool, + new_budget: NewBudgetEntity, + user_id: i32, +) -> Result<(BudgetEntity, Vec), AppError> { + let NewBudgetEntity { + name, + description, + icon, + } = &new_budget; + + let new_budget = sqlx::query_as::<_, BudgetEntity>("INSERT INTO public.budget (name, description, icon) VALUES ($1, $2, $3) RETURNING id, name, description, icon, status;") + .bind(name) + .bind(description) + .bind(icon) + .fetch_one(pool).await + .inspect_err(|err| error!(?err, record = ?new_budget, "Cannot insert new budget record")) + .map_err(AppError::from)?; + + let budget_permissions = + associate_budget_with_user_as_owner(pool, new_budget.id, user_id).await?; + + Ok((new_budget, budget_permissions)) +} diff --git a/api/src/db/mod.rs b/api/src/db/mod.rs index e751036..e56d501 100644 --- a/api/src/db/mod.rs +++ b/api/src/db/mod.rs @@ -1,18 +1,22 @@ mod account; mod account_type; +mod budget; mod permission; mod status; mod user; mod user_account_permission; +mod user_budget_permission; pub use account::*; pub use account_type::*; +pub use budget::*; pub use permission::*; pub use status::*; pub use user::*; pub use user_account_permission::*; +pub use user_budget_permission::*; -use sqlx::{self, postgres::PgPoolOptions, Pool, Postgres}; +use sqlx::{self, Pool, Postgres, postgres::PgPoolOptions}; use crate::models::AppError; diff --git a/api/src/db/permission.rs b/api/src/db/permission.rs index 0a3f149..f5a7855 100644 --- a/api/src/db/permission.rs +++ b/api/src/db/permission.rs @@ -12,6 +12,7 @@ use super::DbPool; #[sqlx(type_name = "permission_category", rename_all = "PascalCase")] pub enum PermissionCategoryType { Account, + Budget, } #[allow(dead_code)] diff --git a/api/src/db/user_budget_permission.rs b/api/src/db/user_budget_permission.rs new file mode 100644 index 0000000..8872e05 --- /dev/null +++ b/api/src/db/user_budget_permission.rs @@ -0,0 +1,61 @@ +use sqlx::prelude::*; +use tracing::error; +use uuid::Uuid; + +use crate::models::AppError; + +use super::{ + DbPool, PermissionCategoryType, PermissionEntity, StatusType, get_all_permissions_by_category, + get_many_permissions_by_id, +}; + +#[allow(dead_code)] +#[derive(Clone)] +pub struct NewUserBudgetPermissionEntity { + pub user_id: i32, + pub budget_id: Uuid, + pub permission_id: i32, +} + +#[allow(dead_code)] +#[derive(Debug, FromRow)] +pub struct UserBudgetPermissionEntity { + pub id: i32, + pub user_id: i32, + pub budget_id: Uuid, + pub permission_id: i32, + pub status: StatusType, +} + +pub async fn associate_budget_with_user_as_owner( + pool: &DbPool, + budget_id: Uuid, + user_id: i32, +) -> Result, AppError> { + let values = get_all_permissions_by_category(pool, PermissionCategoryType::Budget) + .await? + .into_iter() + .map(|permission| format!("({user_id}, {budget_id}, {})", permission.id)) + .collect::>() + .join(","); + + let query = format!( + "INSERT INTO public.user_budget_permission (user_id, budget_id, permission_id) VALUES {values} RETURNING id, user_id, budget_id, permission_id, status;" + ); + + let permission_ids = sqlx::query_as::<_, UserBudgetPermissionEntity>(query.as_str()) + .fetch_all(pool) + .await + .inspect_err( + |err| error!(?err, %budget_id, user_id, "Cannot associate budget with user as owner"), + ) + .map_err(AppError::from) + .map(|relations| { + relations + .into_iter() + .map(|relation| relation.permission_id) + .collect::>() + })?; + + get_many_permissions_by_id(pool, permission_ids.as_slice()).await +} diff --git a/api/src/requests/budget/create/handler.rs b/api/src/requests/budget/create/handler.rs new file mode 100644 index 0000000..66390f7 --- /dev/null +++ b/api/src/requests/budget/create/handler.rs @@ -0,0 +1,52 @@ +use axum::{ + Json, debug_handler, + extract::State, + response::{IntoResponse, Response}, +}; +use http::StatusCode; + +use crate::{ + db::{DbPool, NewBudgetEntity, insert_new_budget_for_user}, + models::{AppError, Session}, + requests::AppState, +}; + +use super::models::{BudgetCreationRequest, BudgetCreationResponse}; + +#[debug_handler] +pub async fn budget_create_post_request( + State(state): State, + session: Session, + Json(request): Json, +) -> Result { + let pool = state.db_pool(); + let user_id = session.user_id; + + budget_creation_request(pool, user_id, request) + .await + .map(|(status_code, response)| (status_code, Json(response)).into_response()) +} + +async fn budget_creation_request( + pool: &DbPool, + user_id: i32, + request: BudgetCreationRequest, +) -> Result<(StatusCode, BudgetCreationResponse), AppError> { + let BudgetCreationRequest { + name, + description, + icon, + } = request; + + let new_budget = NewBudgetEntity { + name, + description, + icon, + }; + + let response = insert_new_budget_for_user(pool, new_budget, user_id) + .await + .map(BudgetCreationResponse::from)?; + + Ok((StatusCode::CREATED, response)) +} diff --git a/api/src/requests/budget/create/mod.rs b/api/src/requests/budget/create/mod.rs new file mode 100644 index 0000000..f00e7ca --- /dev/null +++ b/api/src/requests/budget/create/mod.rs @@ -0,0 +1,4 @@ +mod handler; +mod models; + +pub use handler::*; diff --git a/api/src/requests/budget/create/models/mod.rs b/api/src/requests/budget/create/models/mod.rs new file mode 100644 index 0000000..b8be632 --- /dev/null +++ b/api/src/requests/budget/create/models/mod.rs @@ -0,0 +1,5 @@ +mod request; +mod response; + +pub use request::*; +pub use response::*; diff --git a/api/src/requests/budget/create/models/request.rs b/api/src/requests/budget/create/models/request.rs new file mode 100644 index 0000000..dff1108 --- /dev/null +++ b/api/src/requests/budget/create/models/request.rs @@ -0,0 +1,9 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BudgetCreationRequest { + pub name: String, + pub description: Option, + pub icon: Option, +} diff --git a/api/src/requests/budget/create/models/response.rs b/api/src/requests/budget/create/models/response.rs new file mode 100644 index 0000000..759c100 --- /dev/null +++ b/api/src/requests/budget/create/models/response.rs @@ -0,0 +1,40 @@ +use serde::Serialize; +use uuid::Uuid; + +use crate::db::{BudgetEntity, PermissionEntity}; + +#[derive(Debug, Serialize)] +pub struct BudgetCreationResponse { + pub id: Uuid, + pub name: String, + pub description: Option, + pub icon: Option, + pub permissions: i32, +} + +impl From<(BudgetEntity, Vec)> for BudgetCreationResponse { + fn from(other: (BudgetEntity, Vec)) -> Self { + let (budget_entity, permissions) = other; + + let BudgetEntity { + id, + name, + description, + icon, + .. + } = budget_entity; + + let permissions = permissions + .into_iter() + .map(|permission| permission.value) + .sum(); + + Self { + id, + name, + description, + icon, + permissions, + } + } +} diff --git a/api/src/requests/budget/mod.rs b/api/src/requests/budget/mod.rs new file mode 100644 index 0000000..ee6bc49 --- /dev/null +++ b/api/src/requests/budget/mod.rs @@ -0,0 +1,15 @@ +use axum::{Router, routing::post}; +use create::budget_create_post_request; + +use super::AppState; + +mod create; + +pub fn requests(state: AppState) -> Router { + Router::new().nest( + "/budget", + Router::new() + .route("/", post(budget_create_post_request)) + .with_state(state), + ) +} diff --git a/api/src/requests/mod.rs b/api/src/requests/mod.rs index bb2ea88..7b6288e 100644 --- a/api/src/requests/mod.rs +++ b/api/src/requests/mod.rs @@ -1,24 +1,25 @@ mod account; mod auth; +mod budget; mod user; use std::{sync::mpsc::Sender, time::Duration}; use axum::{ + Router, extract::{MatchedPath, Request}, response::Response, - Router, }; use humantime::format_duration; use tokio::net::TcpListener; use tower_http::trace::TraceLayer; -use tracing::{error, info, info_span, warn, Span}; +use tracing::{Span, error, info, info_span, warn}; use uuid::Uuid; use crate::{ db::DbPool, models::AppError, - services::{config::Config, CachePool, UserConfirmationMessage}, + services::{CachePool, UserConfirmationMessage, config::Config}, }; #[derive(Clone)] @@ -96,6 +97,7 @@ pub async fn start_app( .merge(user::requests(state.clone())) .merge(auth::requests(state.clone())) .merge(account::requests(state.clone())) + .merge(budget::requests(state.clone())) .layer(logging_layer); info!("API started successfully.");