раскидал структуру для монорепозитория

This commit is contained in:
arrelin
2025-12-09 18:31:45 +03:00
parent 7e1e89424a
commit aadbc099b0
48 changed files with 4048 additions and 5 deletions

90
backend/src/auth.rs Normal file
View File

@@ -0,0 +1,90 @@
use axum_login::{AuthUser, AuthnBackend, UserId};
use sea_orm::{DatabaseConnection, EntityTrait, ColumnTrait, QueryFilter};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use crate::models::{user, User};
#[derive(Debug, Clone)]
pub struct AuthBackend {
pub db: DatabaseConnection,
}
impl AuthUser for user::Model {
type Id = i32;
fn id(&self) -> Self::Id {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
self.password_hash.as_bytes()
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Database error: {0}")]
Database(#[from] sea_orm::DbErr),
#[error("Password hashing error")]
PasswordHash,
#[error("Invalid credentials")]
InvalidCredentials,
}
#[derive(Debug, Clone)]
pub struct Credentials {
pub username: String,
pub password: String,
}
#[async_trait::async_trait]
impl AuthnBackend for AuthBackend {
type User = user::Model;
type Credentials = Credentials;
type Error = Error;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let user = User::find()
.filter(user::Column::Username.eq(&creds.username))
.one(&self.db)
.await?;
if let Some(user) = user {
let parsed_hash = PasswordHash::new(&user.password_hash)
.map_err(|_| Error::PasswordHash)?;
let is_valid = Argon2::default()
.verify_password(creds.password.as_bytes(), &parsed_hash)
.is_ok();
if is_valid {
Ok(Some(user))
} else {
Err(Error::InvalidCredentials)
}
} else {
Err(Error::InvalidCredentials)
}
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
let user = User::find_by_id(*user_id).one(&self.db).await?;
Ok(user)
}
}
pub fn hash_password(password: &str) -> Result<String, Error> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|_| Error::PasswordHash)
}

160
backend/src/lib.rs Normal file
View File

@@ -0,0 +1,160 @@
use axum::{
routing::{get, post, put, delete},
Router, middleware as axum_middleware,
};
use sea_orm::{Database, DatabaseConnection, DbErr};
use sea_orm_migration::prelude::*;
use std::net::SocketAddr;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::PostgresStore;
use axum_login::AuthManagerLayerBuilder;
use time::Duration;
use tower_http::cors::CorsLayer;
use axum::http::{Method, HeaderValue};
pub mod models;
pub mod services;
pub mod migration;
pub mod routes;
pub mod auth;
pub mod middleware;
pub use auth::AuthBackend;
pub use middleware::require_admin;
#[derive(OpenApi)]
#[openapi(
paths(
routes::auth::login,
routes::auth::logout,
routes::family::create_family,
routes::family::get_family,
routes::family::get_all_families,
routes::family::update_family,
routes::family::delete_family,
routes::category::create_category,
routes::category::get_category,
routes::category::get_categories_by_family,
routes::category::update_category,
routes::category::delete_category,
routes::expense::create_expense,
routes::expense::get_expense,
routes::expense::get_expenses_by_category,
routes::expense::update_expense,
routes::expense::delete_expense,
routes::expense::get_remaining_limit,
),
components(
schemas(
models::family::Model,
models::category::Model,
models::expense::Model,
routes::auth::LoginRequest,
routes::auth::LoginResponse,
routes::family::CreateFamilyRequest,
routes::family::UpdateFamilyRequest,
routes::category::CreateCategoryRequest,
routes::category::UpdateCategoryRequest,
routes::expense::CreateExpenseRequest,
routes::expense::UpdateExpenseRequest,
routes::expense::RemainingLimitResponse,
)
),
tags(
(name = "auth", description = "Authentication endpoints"),
(name = "families", description = "Family management endpoints"),
(name = "categories", description = "Category management endpoints"),
(name = "expenses", description = "Expense management endpoints")
),
info(
title = "Family Budget API",
version = "0.1.0",
description = "REST API"
)
)]
struct ApiDoc;
pub async fn establish_connection() -> Result<DatabaseConnection, DbErr> {
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set in .env file");
Database::connect(&database_url).await
}
pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set in .env file");
let session_store = PostgresStore::new(
sqlx::PgPool::connect(&database_url)
.await
.expect("Failed to connect to database for sessions"),
);
session_store
.migrate()
.await
.expect("Failed to run session store migrations");
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false)
.with_expiry(Expiry::OnInactivity(Duration::days(1)));
let backend = auth::AuthBackend { db: db.clone() };
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
let protected_routes = Router::new()
.route("/families", post(routes::family::create_family))
.route_layer(axum_middleware::from_fn(middleware::require_admin));
let api_routes = Router::new()
.route("/login", post(routes::auth::login))
.route("/logout", post(routes::auth::logout))
.merge(protected_routes)
.route("/families", get(routes::family::get_all_families))
.route("/families/{id}", get(routes::family::get_family))
.route("/families/{id}", put(routes::family::update_family))
.route("/families/{id}", delete(routes::family::delete_family))
.route("/families/{family_id}/categories", post(routes::category::create_category))
.route("/families/{family_id}/categories", get(routes::category::get_categories_by_family))
.route("/families/{family_id}/categories/{category_id}", get(routes::category::get_category))
.route("/families/{family_id}/categories/{category_id}", put(routes::category::update_category))
.route("/families/{family_id}/categories/{category_id}", delete(routes::category::delete_category))
.route("/families/{family_id}/categories/{category_id}/expenses", post(routes::expense::create_expense))
.route("/families/{family_id}/categories/{category_id}/expenses", get(routes::expense::get_expenses_by_category))
.route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", get(routes::expense::get_expense))
.route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", put(routes::expense::update_expense))
.route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", delete(routes::expense::delete_expense))
.route("/families/{family_id}/categories/{category_id}/remaining", get(routes::expense::get_remaining_limit))
.layer(auth_layer)
.with_state(db);
let swagger_ui = SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", ApiDoc::openapi());
let cors = CorsLayer::new()
.allow_origin([
"http://localhost:3000".parse::<HeaderValue>().unwrap(),
"http://localhost:8080".parse::<HeaderValue>().unwrap(),
])
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
.allow_headers([
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
axum::http::header::ACCEPT,
])
.allow_credentials(true);
let app = api_routes
.layer(cors)
.merge(swagger_ui);
Ok(app)
}
pub fn server_address() -> SocketAddr {
SocketAddr::from(([0, 0, 0, 0], 8080))
}

24
backend/src/main.rs Normal file
View File

@@ -0,0 +1,24 @@
use family_budget::*;
use sea_orm::DbErr;
use sea_orm_migration::prelude::*;
#[tokio::main]
async fn main() -> Result<(), DbErr> {
let db = establish_connection().await?;
println!("Successfully connected to database!");
println!("Running migrations...");
migration::Migrator::up(&db, None).await?;
println!("Migrations completed!");
let app = create_app(db).await?;
let addr = server_address();
println!("Server running on http://{}", addr);
println!("Swagger UI available at http://{}/swagger-ui", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
Ok(())
}

23
backend/src/middleware.rs Normal file
View File

@@ -0,0 +1,23 @@
use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::Response,
};
use axum_login::AuthSession;
use crate::auth::AuthBackend;
pub async fn require_admin(
auth_session: AuthSession<AuthBackend>,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?;
if !user.is_admin {
return Err(StatusCode::FORBIDDEN);
}
Ok(next.run(request).await)
}

View File

@@ -0,0 +1,137 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Family::Table)
.if_not_exists()
.col(
ColumnDef::new(Family::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Family::Name).string().not_null())
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Category::Table)
.if_not_exists()
.col(
ColumnDef::new(Category::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Category::FamilyId).integer().not_null())
.col(ColumnDef::new(Category::Name).string().not_null())
.col(ColumnDef::new(Category::LimitAmount).decimal().not_null())
.col(
ColumnDef::new(Category::CreatedAt)
.timestamp()
.not_null()
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk_category_family")
.from(Category::Table, Category::FamilyId)
.to(Family::Table, Family::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Expense::Table)
.if_not_exists()
.col(
ColumnDef::new(Expense::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Expense::CategoryId).integer().not_null())
.col(ColumnDef::new(Expense::Amount).decimal().not_null())
.col(ColumnDef::new(Expense::Description).string())
.col(
ColumnDef::new(Expense::CreatedAt)
.timestamp()
.not_null()
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk_expense_category")
.from(Expense::Table, Expense::CategoryId)
.to(Category::Table, Category::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Expense::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Category::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Family::Table).to_owned())
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum Family {
Table,
Id,
Name,
}
#[derive(DeriveIden)]
enum Category {
Table,
Id,
FamilyId,
Name,
LimitAmount,
CreatedAt,
}
#[derive(DeriveIden)]
enum Expense {
Table,
Id,
CategoryId,
Amount,
Description,
CreatedAt,
}

View File

@@ -0,0 +1,57 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(
ColumnDef::new(User::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(User::Username)
.string()
.not_null()
.unique_key(),
)
.col(ColumnDef::new(User::PasswordHash).string().not_null())
.col(
ColumnDef::new(User::IsAdmin)
.boolean()
.not_null()
.default(false),
)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum User {
Table,
Id,
Username,
PasswordHash,
IsAdmin,
}

View File

@@ -0,0 +1,48 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password("2123".as_bytes(), &salt)
.map_err(|_| DbErr::Migration("Failed to hash password".to_string()))?
.to_string();
let insert = Query::insert()
.into_table(User::Table)
.columns([User::Username, User::PasswordHash, User::IsAdmin])
.values_panic(["admin".into(), password_hash.into(), true.into()])
.to_owned();
manager.exec_stmt(insert).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let delete = Query::delete()
.from_table(User::Table)
.and_where(Expr::col(User::Username).eq("admin"))
.to_owned();
manager.exec_stmt(delete).await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum User {
Table,
Username,
PasswordHash,
IsAdmin,
}

View File

@@ -0,0 +1,18 @@
pub use sea_orm_migration::prelude::*;
mod m20241209_000001_create_tables;
mod m20241209_000002_create_users;
mod m20241209_000003_seed_admin;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20241209_000001_create_tables::Migration),
Box::new(m20241209_000002_create_users::Migration),
Box::new(m20241209_000003_seed_admin::Migration),
]
}
}

View File

@@ -0,0 +1,41 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, ToSchema)]
#[sea_orm(table_name = "category")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub family_id: i32,
pub name: String,
pub limit_amount: Decimal,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::family::Entity",
from = "Column::FamilyId",
to = "super::family::Column::Id"
)]
Family,
#[sea_orm(has_many = "super::expense::Entity")]
Expense,
}
impl Related<super::family::Entity> for Entity {
fn to() -> RelationDef {
Relation::Family.def()
}
}
impl Related<super::expense::Entity> for Entity {
fn to() -> RelationDef {
Relation::Expense.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,32 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, ToSchema)]
#[sea_orm(table_name = "expense")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub category_id: i32,
pub amount: Decimal,
pub description: Option<String>,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::category::Entity",
from = "Column::CategoryId",
to = "super::category::Column::Id"
)]
Category,
}
impl Related<super::category::Entity> for Entity {
fn to() -> RelationDef {
Relation::Category.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,25 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, ToSchema)]
#[sea_orm(table_name = "family")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::category::Entity")]
Category,
}
impl Related<super::category::Entity> for Entity {
fn to() -> RelationDef {
Relation::Category.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,9 @@
pub mod family;
pub mod category;
pub mod expense;
pub mod user;
pub use family::Entity as Family;
pub use category::Entity as Category;
pub use expense::Entity as Expense;
pub use user::Entity as User;

View File

@@ -0,0 +1,18 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub username: String,
pub password_hash: String,
pub is_admin: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,75 @@
use axum::{
extract::State,
http::StatusCode,
Json,
};
use axum_login::AuthSession;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::auth::{AuthBackend, Credentials};
#[derive(Debug, Deserialize, ToSchema)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct LoginResponse {
pub success: bool,
pub is_admin: bool,
}
#[utoipa::path(
post,
path = "/login",
tag = "auth",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful", body = LoginResponse),
(status = 401, description = "Invalid credentials")
)
)]
pub async fn login(
mut auth_session: AuthSession<AuthBackend>,
Json(payload): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {
let user = auth_session
.authenticate(Credentials {
username: payload.username,
password: payload.password,
})
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?
.ok_or(StatusCode::UNAUTHORIZED)?;
auth_session
.login(&user)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(LoginResponse {
success: true,
is_admin: user.is_admin,
}))
}
#[utoipa::path(
post,
path = "/logout",
tag = "auth",
responses(
(status = 200, description = "Logout successful")
)
)]
pub async fn logout(
mut auth_session: AuthSession<AuthBackend>,
) -> Result<StatusCode, StatusCode> {
auth_session
.logout()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::OK)
}

View File

@@ -0,0 +1,170 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use sea_orm::{prelude::Decimal, DatabaseConnection};
use serde::Deserialize;
use serde_json::json;
use utoipa::ToSchema;
use crate::models::category::Model as CategoryModel;
use crate::services::CategoryService;
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({"name": "Groceries", "limit_amount": 5000.00}))]
pub struct CreateCategoryRequest {
pub name: String,
pub limit_amount: Decimal,
}
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({"name": "Monthly Groceries", "limit_amount": 6000.00}))]
pub struct UpdateCategoryRequest {
pub name: Option<String>,
pub limit_amount: Option<Decimal>,
}
#[utoipa::path(
post,
path = "/families/{family_id}/categories",
tag = "categories",
params(
("family_id" = i32, Path, description = "Family ID")
),
request_body = CreateCategoryRequest,
responses(
(status = 200, description = "Category created successfully", body = CategoryModel),
(status = 500, description = "Internal server error")
)
)]
pub async fn create_category(
State(db): State<DatabaseConnection>,
Path(family_id): Path<i32>,
Json(payload): Json<CreateCategoryRequest>,
) -> Result<Json<CategoryModel>, StatusCode> {
CategoryService::create(&db, family_id, payload.name, payload.limit_amount)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
get,
path = "/families/{family_id}/categories/{category_id}",
tag = "categories",
params(
("family_id" = i32, Path, description = "Family ID"),
("category_id" = i32, Path, description = "Category ID")
),
responses(
(status = 200, description = "Category found", body = CategoryModel),
(status = 404, description = "Category not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_category(
State(db): State<DatabaseConnection>,
Path((family_id, category_id)): Path<(i32, i32)>,
) -> Result<Json<CategoryModel>, StatusCode> {
let category = CategoryService::find_by_id(&db, category_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
if category.family_id != family_id {
return Err(StatusCode::NOT_FOUND);
}
Ok(Json(category))
}
#[utoipa::path(
get,
path = "/families/{family_id}/categories",
tag = "categories",
params(
("family_id" = i32, Path, description = "Family ID")
),
responses(
(status = 200, description = "List of categories for the family", body = Vec<CategoryModel>),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_categories_by_family(
State(db): State<DatabaseConnection>,
Path(family_id): Path<i32>,
) -> Result<Json<Vec<CategoryModel>>, StatusCode> {
CategoryService::find_by_family_id(&db, family_id)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
put,
path = "/families/{family_id}/categories/{category_id}",
tag = "categories",
params(
("family_id" = i32, Path, description = "Family ID"),
("category_id" = i32, Path, description = "Category ID")
),
request_body = UpdateCategoryRequest,
responses(
(status = 200, description = "Category updated successfully", body = CategoryModel),
(status = 404, description = "Category not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn update_category(
State(db): State<DatabaseConnection>,
Path((family_id, category_id)): Path<(i32, i32)>,
Json(payload): Json<UpdateCategoryRequest>,
) -> Result<Json<CategoryModel>, StatusCode> {
let category = CategoryService::find_by_id(&db, category_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
if category.family_id != family_id {
return Err(StatusCode::NOT_FOUND);
}
CategoryService::update(&db, category_id, payload.name, payload.limit_amount)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
delete,
path = "/families/{family_id}/categories/{category_id}",
tag = "categories",
params(
("family_id" = i32, Path, description = "Family ID"),
("category_id" = i32, Path, description = "Category ID")
),
responses(
(status = 204, description = "Category deleted successfully"),
(status = 404, description = "Category not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn delete_category(
State(db): State<DatabaseConnection>,
Path((family_id, category_id)): Path<(i32, i32)>,
) -> Result<StatusCode, StatusCode> {
let category = CategoryService::find_by_id(&db, category_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
if category.family_id != family_id {
return Err(StatusCode::NOT_FOUND);
}
CategoryService::delete(&db, category_id)
.await
.map(|_| StatusCode::NO_CONTENT)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

View File

@@ -0,0 +1,266 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use sea_orm::{prelude::Decimal, DatabaseConnection};
use serde::{Deserialize, Serialize};
use serde_json::json;
use utoipa::ToSchema;
use crate::models::expense::Model as ExpenseModel;
use crate::services::{CategoryService, ExpenseService};
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({"amount": 150.50, "description": "Weekly grocery shopping"}))]
pub struct CreateExpenseRequest {
pub amount: Decimal,
pub description: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({"amount": 200.00, "description": "Updated expense description"}))]
pub struct UpdateExpenseRequest {
pub amount: Option<Decimal>,
pub description: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
#[schema(example = json!({"category_id": 1, "remaining_limit": 4500.00}))]
pub struct RemainingLimitResponse {
pub category_id: i32,
pub remaining_limit: Decimal,
}
#[utoipa::path(
post,
path = "/families/{family_id}/categories/{category_id}/expenses",
tag = "expenses",
params(
("family_id" = i32, Path, description = "Family ID"),
("category_id" = i32, Path, description = "Category ID")
),
request_body = CreateExpenseRequest,
responses(
(status = 200, description = "Expense created successfully", body = ExpenseModel),
(status = 404, description = "Category not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn create_expense(
State(db): State<DatabaseConnection>,
Path((family_id, category_id)): Path<(i32, i32)>,
Json(payload): Json<CreateExpenseRequest>,
) -> Result<Json<ExpenseModel>, StatusCode> {
let category = CategoryService::find_by_id(&db, category_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
if category.family_id != family_id {
return Err(StatusCode::NOT_FOUND);
}
ExpenseService::create(&db, category_id, payload.amount, payload.description)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
get,
path = "/families/{family_id}/categories/{category_id}/expenses/{expense_id}",
tag = "expenses",
params(
("family_id" = i32, Path, description = "Family ID"),
("category_id" = i32, Path, description = "Category ID"),
("expense_id" = i32, Path, description = "Expense ID")
),
responses(
(status = 200, description = "Expense found", body = ExpenseModel),
(status = 404, description = "Expense not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_expense(
State(db): State<DatabaseConnection>,
Path((family_id, category_id, expense_id)): Path<(i32, i32, i32)>,
) -> Result<Json<ExpenseModel>, StatusCode> {
let category = CategoryService::find_by_id(&db, category_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
if category.family_id != family_id {
return Err(StatusCode::NOT_FOUND);
}
let expense = ExpenseService::find_by_id(&db, expense_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
if expense.category_id != category_id {
return Err(StatusCode::NOT_FOUND);
}
Ok(Json(expense))
}
#[utoipa::path(
get,
path = "/families/{family_id}/categories/{category_id}/expenses",
tag = "expenses",
params(
("family_id" = i32, Path, description = "Family ID"),
("category_id" = i32, Path, description = "Category ID")
),
responses(
(status = 200, description = "List of expenses for the category", body = Vec<ExpenseModel>),
(status = 404, description = "Category not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_expenses_by_category(
State(db): State<DatabaseConnection>,
Path((family_id, category_id)): Path<(i32, i32)>,
) -> Result<Json<Vec<ExpenseModel>>, StatusCode> {
let category = CategoryService::find_by_id(&db, category_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
if category.family_id != family_id {
return Err(StatusCode::NOT_FOUND);
}
ExpenseService::find_by_category_id(&db, category_id)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
put,
path = "/families/{family_id}/categories/{category_id}/expenses/{expense_id}",
tag = "expenses",
params(
("family_id" = i32, Path, description = "Family ID"),
("category_id" = i32, Path, description = "Category ID"),
("expense_id" = i32, Path, description = "Expense ID")
),
request_body = UpdateExpenseRequest,
responses(
(status = 200, description = "Expense updated successfully", body = ExpenseModel),
(status = 404, description = "Expense not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn update_expense(
State(db): State<DatabaseConnection>,
Path((family_id, category_id, expense_id)): Path<(i32, i32, i32)>,
Json(payload): Json<UpdateExpenseRequest>,
) -> Result<Json<ExpenseModel>, StatusCode> {
let category = CategoryService::find_by_id(&db, category_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
if category.family_id != family_id {
return Err(StatusCode::NOT_FOUND);
}
let expense = ExpenseService::find_by_id(&db, expense_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
if expense.category_id != category_id {
return Err(StatusCode::NOT_FOUND);
}
ExpenseService::update(&db, expense_id, payload.amount, payload.description)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
delete,
path = "/families/{family_id}/categories/{category_id}/expenses/{expense_id}",
tag = "expenses",
params(
("family_id" = i32, Path, description = "Family ID"),
("category_id" = i32, Path, description = "Category ID"),
("expense_id" = i32, Path, description = "Expense ID")
),
responses(
(status = 204, description = "Expense deleted successfully"),
(status = 404, description = "Expense not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn delete_expense(
State(db): State<DatabaseConnection>,
Path((family_id, category_id, expense_id)): Path<(i32, i32, i32)>,
) -> Result<StatusCode, StatusCode> {
let category = CategoryService::find_by_id(&db, category_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
if category.family_id != family_id {
return Err(StatusCode::NOT_FOUND);
}
let expense = ExpenseService::find_by_id(&db, expense_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
if expense.category_id != category_id {
return Err(StatusCode::NOT_FOUND);
}
ExpenseService::delete(&db, expense_id)
.await
.map(|_| StatusCode::NO_CONTENT)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
get,
path = "/families/{family_id}/categories/{category_id}/remaining",
tag = "expenses",
params(
("family_id" = i32, Path, description = "Family ID"),
("category_id" = i32, Path, description = "Category ID")
),
responses(
(status = 200, description = "Remaining budget limit", body = RemainingLimitResponse),
(status = 404, description = "Category not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_remaining_limit(
State(db): State<DatabaseConnection>,
Path((family_id, category_id)): Path<(i32, i32)>,
) -> Result<Json<RemainingLimitResponse>, StatusCode> {
let category = CategoryService::find_by_id(&db, category_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
if category.family_id != family_id {
return Err(StatusCode::NOT_FOUND);
}
let remaining = ExpenseService::calculate_remaining_limit(&db, category_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(RemainingLimitResponse {
category_id,
remaining_limit: remaining,
}))
}

View File

@@ -0,0 +1,132 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use sea_orm::DatabaseConnection;
use serde::Deserialize;
use serde_json::json;
use utoipa::ToSchema;
use crate::models::family::Model as FamilyModel;
use crate::services::FamilyService;
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({"name": "Smith Family"}))]
pub struct CreateFamilyRequest {
pub name: String,
}
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({"name": "Updated Family Name"}))]
pub struct UpdateFamilyRequest {
pub name: String,
}
#[utoipa::path(
post,
path = "/families",
tag = "families",
request_body = CreateFamilyRequest,
responses(
(status = 200, description = "Family created successfully", body = FamilyModel),
(status = 500, description = "Internal server error")
)
)]
pub async fn create_family(
State(db): State<DatabaseConnection>,
Json(payload): Json<CreateFamilyRequest>,
) -> Result<Json<FamilyModel>, StatusCode> {
FamilyService::create(&db, payload.name)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
get,
path = "/families/{id}",
tag = "families",
params(
("id" = i32, Path, description = "Family ID")
),
responses(
(status = 200, description = "Family found", body = FamilyModel),
(status = 404, description = "Family not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_family(
State(db): State<DatabaseConnection>,
Path(id): Path<i32>,
) -> Result<Json<FamilyModel>, StatusCode> {
FamilyService::find_by_id(&db, id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
#[utoipa::path(
get,
path = "/families",
tag = "families",
responses(
(status = 200, description = "List of all families", body = Vec<FamilyModel>),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_all_families(
State(db): State<DatabaseConnection>,
) -> Result<Json<Vec<FamilyModel>>, StatusCode> {
FamilyService::find_all(&db)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
put,
path = "/families/{id}",
tag = "families",
params(
("id" = i32, Path, description = "Family ID")
),
request_body = UpdateFamilyRequest,
responses(
(status = 200, description = "Family updated successfully", body = FamilyModel),
(status = 500, description = "Internal server error")
)
)]
pub async fn update_family(
State(db): State<DatabaseConnection>,
Path(id): Path<i32>,
Json(payload): Json<UpdateFamilyRequest>,
) -> Result<Json<FamilyModel>, StatusCode> {
FamilyService::update(&db, id, payload.name)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
delete,
path = "/families/{id}",
tag = "families",
params(
("id" = i32, Path, description = "Family ID")
),
responses(
(status = 204, description = "Family deleted successfully"),
(status = 500, description = "Internal server error")
)
)]
pub async fn delete_family(
State(db): State<DatabaseConnection>,
Path(id): Path<i32>,
) -> Result<StatusCode, StatusCode> {
FamilyService::delete(&db, id)
.await
.map(|_| StatusCode::NO_CONTENT)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

View File

@@ -0,0 +1,4 @@
pub mod family;
pub mod category;
pub mod expense;
pub mod auth;

View File

@@ -0,0 +1,77 @@
use sea_orm::*;
use sea_orm::prelude::Decimal;
use chrono::Utc;
use crate::models::category::{self, Entity as Category, Model as CategoryModel};
pub struct CategoryService;
impl CategoryService {
pub async fn create(
db: &DatabaseConnection,
family_id: i32,
name: String,
limit_amount: Decimal,
) -> Result<CategoryModel, DbErr> {
let category = category::ActiveModel {
family_id: Set(family_id),
name: Set(name),
limit_amount: Set(limit_amount),
created_at: Set(Utc::now().naive_utc()),
..Default::default()
};
category.insert(db).await
}
pub async fn find_by_id(db: &DatabaseConnection, id: i32) -> Result<Option<CategoryModel>, DbErr> {
Category::find_by_id(id).one(db).await
}
pub async fn find_all(db: &DatabaseConnection) -> Result<Vec<CategoryModel>, DbErr> {
Category::find().all(db).await
}
pub async fn find_by_family_id(
db: &DatabaseConnection,
family_id: i32,
) -> Result<Vec<CategoryModel>, DbErr> {
Category::find()
.filter(category::Column::FamilyId.eq(family_id))
.all(db)
.await
}
pub async fn update(
db: &DatabaseConnection,
id: i32,
name: Option<String>,
limit_amount: Option<Decimal>,
) -> Result<CategoryModel, DbErr> {
let category = Category::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Category not found".to_string()))?;
let mut category: category::ActiveModel = category.into();
if let Some(name) = name {
category.name = Set(name);
}
if let Some(limit_amount) = limit_amount {
category.limit_amount = Set(limit_amount);
}
category.update(db).await
}
pub async fn delete(db: &DatabaseConnection, id: i32) -> Result<DeleteResult, DbErr> {
let category = Category::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Category not found".to_string()))?;
let category: category::ActiveModel = category.into();
category.delete(db).await
}
}

View File

@@ -0,0 +1,95 @@
use sea_orm::*;
use sea_orm::prelude::Decimal;
use chrono::Utc;
use crate::models::expense::{self, Entity as Expense, Model as ExpenseModel};
use crate::models::category::{Entity as Category};
pub struct ExpenseService;
impl ExpenseService {
pub async fn create(
db: &DatabaseConnection,
category_id: i32,
amount: Decimal,
description: Option<String>,
) -> Result<ExpenseModel, DbErr> {
let expense = expense::ActiveModel {
category_id: Set(category_id),
amount: Set(amount),
description: Set(description),
created_at: Set(Utc::now().naive_utc()),
..Default::default()
};
expense.insert(db).await
}
pub async fn find_by_id(db: &DatabaseConnection, id: i32) -> Result<Option<ExpenseModel>, DbErr> {
Expense::find_by_id(id).one(db).await
}
pub async fn find_all(db: &DatabaseConnection) -> Result<Vec<ExpenseModel>, DbErr> {
Expense::find().all(db).await
}
pub async fn find_by_category_id(
db: &DatabaseConnection,
category_id: i32,
) -> Result<Vec<ExpenseModel>, DbErr> {
Expense::find()
.filter(expense::Column::CategoryId.eq(category_id))
.all(db)
.await
}
pub async fn update(
db: &DatabaseConnection,
id: i32,
amount: Option<Decimal>,
description: Option<String>,
) -> Result<ExpenseModel, DbErr> {
let expense = Expense::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Expense not found".to_string()))?;
let mut expense: expense::ActiveModel = expense.into();
if let Some(amount) = amount {
expense.amount = Set(amount);
}
if let Some(desc) = description {
expense.description = Set(Some(desc));
}
expense.update(db).await
}
pub async fn delete(db: &DatabaseConnection, id: i32) -> Result<DeleteResult, DbErr> {
let expense = Expense::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Expense not found".to_string()))?;
let expense: expense::ActiveModel = expense.into();
expense.delete(db).await
}
pub async fn calculate_remaining_limit(
db: &DatabaseConnection,
category_id: i32,
) -> Result<Decimal, DbErr> {
let category = Category::find_by_id(category_id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Category not found".to_string()))?;
let expenses = Self::find_by_category_id(db, category_id).await?;
let total_spent: Decimal = expenses.iter().map(|e| e.amount).sum();
let remaining = category.limit_amount - total_spent;
Ok(remaining)
}
}

View File

@@ -0,0 +1,49 @@
use sea_orm::*;
use crate::models::family::{self, Entity as Family, Model as FamilyModel};
pub struct FamilyService;
impl FamilyService {
pub async fn create(db: &DatabaseConnection, name: String) -> Result<FamilyModel, DbErr> {
let family = family::ActiveModel {
name: Set(name),
..Default::default()
};
family.insert(db).await
}
pub async fn find_by_id(db: &DatabaseConnection, id: i32) -> Result<Option<FamilyModel>, DbErr> {
Family::find_by_id(id).one(db).await
}
pub async fn find_all(db: &DatabaseConnection) -> Result<Vec<FamilyModel>, DbErr> {
Family::find().all(db).await
}
pub async fn update(
db: &DatabaseConnection,
id: i32,
name: String,
) -> Result<FamilyModel, DbErr> {
let family = Family::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Family not found".to_string()))?;
let mut family: family::ActiveModel = family.into();
family.name = Set(name);
family.update(db).await
}
pub async fn delete(db: &DatabaseConnection, id: i32) -> Result<DeleteResult, DbErr> {
let family = Family::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Family not found".to_string()))?;
let family: family::ActiveModel = family.into();
family.delete(db).await
}
}

View File

@@ -0,0 +1,7 @@
pub mod family_service;
pub mod category_service;
pub mod expense_service;
pub use family_service::FamilyService;
pub use category_service::CategoryService;
pub use expense_service::ExpenseService;