Create budget creation request

This commit is contained in:
Z. Charles Dziura 2025-03-12 16:53:44 -04:00
parent efd9e80633
commit cd013d0009
13 changed files with 275 additions and 11 deletions

View file

@ -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;

View file

@ -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(),

50
api/src/db/budget.rs Normal file
View file

@ -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<String>,
pub icon: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, FromRow)]
pub struct BudgetEntity {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub status: StatusType,
}
pub async fn insert_new_budget_for_user(
pool: &DbPool,
new_budget: NewBudgetEntity,
user_id: i32,
) -> Result<(BudgetEntity, Vec<PermissionEntity>), 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))
}

View file

@ -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;

View file

@ -12,6 +12,7 @@ use super::DbPool;
#[sqlx(type_name = "permission_category", rename_all = "PascalCase")]
pub enum PermissionCategoryType {
Account,
Budget,
}
#[allow(dead_code)]

View file

@ -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<Vec<PermissionEntity>, AppError> {
let values = get_all_permissions_by_category(pool, PermissionCategoryType::Budget)
.await?
.into_iter()
.map(|permission| format!("({user_id}, {budget_id}, {})", permission.id))
.collect::<Vec<_>>()
.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::<Vec<_>>()
})?;
get_many_permissions_by_id(pool, permission_ids.as_slice()).await
}

View file

@ -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<AppState>,
session: Session,
Json(request): Json<BudgetCreationRequest>,
) -> Result<Response, AppError> {
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))
}

View file

@ -0,0 +1,4 @@
mod handler;
mod models;
pub use handler::*;

View file

@ -0,0 +1,5 @@
mod request;
mod response;
pub use request::*;
pub use response::*;

View file

@ -0,0 +1,9 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BudgetCreationRequest {
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
}

View file

@ -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<String>,
pub icon: Option<String>,
pub permissions: i32,
}
impl From<(BudgetEntity, Vec<PermissionEntity>)> for BudgetCreationResponse {
fn from(other: (BudgetEntity, Vec<PermissionEntity>)) -> 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,
}
}
}

View file

@ -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),
)
}

View file

@ -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.");