init feature

This commit is contained in:
arrelin
2025-12-24 15:38:36 +03:00
parent 0fdc20e750
commit fcd4199cbd
15 changed files with 994 additions and 2 deletions

View File

@@ -45,12 +45,21 @@ pub use middleware::{require_admin, require_family_access};
routes::expense::update_expense,
routes::expense::delete_expense,
routes::expense::get_remaining_limit,
routes::shopping_item::create_shopping_item,
routes::shopping_item::get_shopping_items_by_family,
routes::shopping_item::get_shopping_item,
routes::shopping_item::update_shopping_item,
routes::shopping_item::delete_shopping_item,
routes::shopping_item::mark_as_purchased,
routes::shopping_item::mark_all_as_purchased,
routes::shopping_item::clear_all,
),
components(
schemas(
models::family::Model,
models::category::Model,
models::expense::Model,
models::shopping_item::Model,
routes::auth::LoginRequest,
routes::auth::LoginResponse,
routes::family::CreateFamilyRequest,
@@ -60,13 +69,18 @@ pub use middleware::{require_admin, require_family_access};
routes::expense::CreateExpenseRequest,
routes::expense::UpdateExpenseRequest,
routes::expense::RemainingLimitResponse,
routes::shopping_item::CreateShoppingItemRequest,
routes::shopping_item::UpdateShoppingItemRequest,
routes::shopping_item::MarkAsPurchasedRequest,
routes::shopping_item::BulkOperationResponse,
)
),
tags(
(name = "auth", description = "Authentication endpoints"),
(name = "families", description = "Family management endpoints"),
(name = "categories", description = "Category management endpoints"),
(name = "expenses", description = "Expense management endpoints")
(name = "expenses", description = "Expense management endpoints"),
(name = "shopping-items", description = "Shopping list management endpoints")
),
info(
title = "Family Budget API",
@@ -131,6 +145,14 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
.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))
.route("/families/:family_id/shopping-items", post(routes::shopping_item::create_shopping_item))
.route("/families/:family_id/shopping-items", get(routes::shopping_item::get_shopping_items_by_family))
.route("/families/:family_id/shopping-items/:id", get(routes::shopping_item::get_shopping_item))
.route("/families/:family_id/shopping-items/:id", put(routes::shopping_item::update_shopping_item))
.route("/families/:family_id/shopping-items/:id", delete(routes::shopping_item::delete_shopping_item))
.route("/families/:family_id/shopping-items/:id/purchased", axum::routing::patch(routes::shopping_item::mark_as_purchased))
.route("/families/:family_id/shopping-items/mark-all-purchased", post(routes::shopping_item::mark_all_as_purchased))
.route("/families/:family_id/shopping-items/clear-all", delete(routes::shopping_item::clear_all))
.route_layer(axum_middleware::from_fn(middleware::require_family_access))
.layer(session_layer.clone())
.with_state(db.clone());

View File

@@ -0,0 +1,69 @@
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(ShoppingItem::Table)
.if_not_exists()
.col(
ColumnDef::new(ShoppingItem::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(ShoppingItem::FamilyId).integer().not_null())
.col(ColumnDef::new(ShoppingItem::Name).string().not_null())
.col(
ColumnDef::new(ShoppingItem::IsPurchased)
.boolean()
.not_null()
.default(false),
)
.col(
ColumnDef::new(ShoppingItem::CreatedAt)
.timestamp()
.not_null()
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk_shopping_item_family")
.from(ShoppingItem::Table, ShoppingItem::FamilyId)
.to(Family::Table, Family::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ShoppingItem::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum ShoppingItem {
Table,
Id,
FamilyId,
Name,
IsPurchased,
CreatedAt,
}
#[derive(DeriveIden)]
enum Family {
Table,
Id,
}

View File

@@ -4,6 +4,7 @@ mod m20241209_000001_create_tables;
mod m20241209_000002_create_users;
mod m20241209_000003_seed_admin;
mod m20241215_000001_add_family_password;
mod m20241224_000001_create_shopping_items;
pub struct Migrator;
@@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
Box::new(m20241209_000002_create_users::Migration),
Box::new(m20241209_000003_seed_admin::Migration),
Box::new(m20241215_000001_add_family_password::Migration),
Box::new(m20241224_000001_create_shopping_items::Migration),
]
}
}

View File

@@ -16,6 +16,8 @@ pub struct Model {
pub enum Relation {
#[sea_orm(has_many = "super::category::Entity")]
Category,
#[sea_orm(has_many = "super::shopping_item::Entity")]
ShoppingItem,
}
impl Related<super::category::Entity> for Entity {
@@ -24,4 +26,10 @@ impl Related<super::category::Entity> for Entity {
}
}
impl Related<super::shopping_item::Entity> for Entity {
fn to() -> RelationDef {
Relation::ShoppingItem.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -2,8 +2,10 @@ pub mod family;
pub mod category;
pub mod expense;
pub mod user;
pub mod shopping_item;
pub use family::Entity as Family;
pub use category::Entity as Category;
pub use expense::Entity as Expense;
pub use user::Entity as User;
pub use shopping_item::Entity as ShoppingItem;

View File

@@ -0,0 +1,34 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, ToSchema)]
#[sea_orm(table_name = "shopping_item")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub family_id: i32,
pub name: String,
pub is_purchased: bool,
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",
on_update = "Cascade",
on_delete = "Cascade"
)]
Family,
}
impl Related<super::family::Entity> for Entity {
fn to() -> RelationDef {
Relation::Family.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -2,3 +2,4 @@ pub mod family;
pub mod category;
pub mod expense;
pub mod auth;
pub mod shopping_item;

View File

@@ -0,0 +1,225 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use sea_orm::DatabaseConnection;
use serde::{Deserialize, Serialize};
#[allow(unused_imports)] // че за хуйня раст?
use serde_json::json;
use utoipa::ToSchema;
use crate::models::shopping_item::Model as ShoppingItemModel;
use crate::services::ShoppingItemService;
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({"name": "Milk"}))]
pub struct CreateShoppingItemRequest {
pub name: String,
}
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({"name": "Updated item name"}))]
pub struct UpdateShoppingItemRequest {
pub name: String,
}
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({"is_purchased": true}))]
pub struct MarkAsPurchasedRequest {
pub is_purchased: bool,
}
#[derive(Debug, Serialize, ToSchema)]
#[schema(example = json!({"affected_rows": 5}))]
pub struct BulkOperationResponse {
pub affected_rows: u64,
}
#[utoipa::path(
post,
path = "/families/{family_id}/shopping-items",
tag = "shopping-items",
params(
("family_id" = i32, Path, description = "Family ID")
),
request_body = CreateShoppingItemRequest,
responses(
(status = 200, description = "Shopping item created successfully", body = ShoppingItemModel),
(status = 500, description = "Internal server error")
)
)]
pub async fn create_shopping_item(
State(db): State<DatabaseConnection>,
Path(family_id): Path<i32>,
Json(payload): Json<CreateShoppingItemRequest>,
) -> Result<Json<ShoppingItemModel>, StatusCode> {
ShoppingItemService::create(&db, family_id, payload.name)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
get,
path = "/families/{family_id}/shopping-items",
tag = "shopping-items",
params(
("family_id" = i32, Path, description = "Family ID")
),
responses(
(status = 200, description = "List of shopping items", body = Vec<ShoppingItemModel>),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_shopping_items_by_family(
State(db): State<DatabaseConnection>,
Path(family_id): Path<i32>,
) -> Result<Json<Vec<ShoppingItemModel>>, StatusCode> {
ShoppingItemService::find_by_family(&db, family_id)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
get,
path = "/families/{family_id}/shopping-items/{id}",
tag = "shopping-items",
params(
("family_id" = i32, Path, description = "Family ID"),
("id" = i32, Path, description = "Shopping item ID")
),
responses(
(status = 200, description = "Shopping item found", body = ShoppingItemModel),
(status = 404, description = "Shopping item not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_shopping_item(
State(db): State<DatabaseConnection>,
Path((_family_id, id)): Path<(i32, i32)>,
) -> Result<Json<ShoppingItemModel>, StatusCode> {
ShoppingItemService::find_by_id(&db, id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
#[utoipa::path(
put,
path = "/families/{family_id}/shopping-items/{id}",
tag = "shopping-items",
params(
("family_id" = i32, Path, description = "Family ID"),
("id" = i32, Path, description = "Shopping item ID")
),
request_body = UpdateShoppingItemRequest,
responses(
(status = 200, description = "Shopping item updated successfully", body = ShoppingItemModel),
(status = 500, description = "Internal server error")
)
)]
pub async fn update_shopping_item(
State(db): State<DatabaseConnection>,
Path((_family_id, id)): Path<(i32, i32)>,
Json(payload): Json<UpdateShoppingItemRequest>,
) -> Result<Json<ShoppingItemModel>, StatusCode> {
ShoppingItemService::update(&db, id, payload.name)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
delete,
path = "/families/{family_id}/shopping-items/{id}",
tag = "shopping-items",
params(
("family_id" = i32, Path, description = "Family ID"),
("id" = i32, Path, description = "Shopping item ID")
),
responses(
(status = 204, description = "Shopping item deleted successfully"),
(status = 500, description = "Internal server error")
)
)]
pub async fn delete_shopping_item(
State(db): State<DatabaseConnection>,
Path((_family_id, id)): Path<(i32, i32)>,
) -> Result<StatusCode, StatusCode> {
ShoppingItemService::delete(&db, id)
.await
.map(|_| StatusCode::NO_CONTENT)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
patch,
path = "/families/{family_id}/shopping-items/{id}/purchased",
tag = "shopping-items",
params(
("family_id" = i32, Path, description = "Family ID"),
("id" = i32, Path, description = "Shopping item ID")
),
request_body = MarkAsPurchasedRequest,
responses(
(status = 200, description = "Shopping item marked as purchased", body = ShoppingItemModel),
(status = 500, description = "Internal server error")
)
)]
pub async fn mark_as_purchased(
State(db): State<DatabaseConnection>,
Path((_family_id, id)): Path<(i32, i32)>,
Json(payload): Json<MarkAsPurchasedRequest>,
) -> Result<Json<ShoppingItemModel>, StatusCode> {
ShoppingItemService::mark_as_purchased(&db, id, payload.is_purchased)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
post,
path = "/families/{family_id}/shopping-items/mark-all-purchased",
tag = "shopping-items",
params(
("family_id" = i32, Path, description = "Family ID")
),
responses(
(status = 200, description = "All shopping items marked as purchased", body = BulkOperationResponse),
(status = 500, description = "Internal server error")
)
)]
pub async fn mark_all_as_purchased(
State(db): State<DatabaseConnection>,
Path(family_id): Path<i32>,
) -> Result<Json<BulkOperationResponse>, StatusCode> {
ShoppingItemService::mark_all_as_purchased(&db, family_id)
.await
.map(|affected_rows| Json(BulkOperationResponse { affected_rows }))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
delete,
path = "/families/{family_id}/shopping-items/clear-all",
tag = "shopping-items",
params(
("family_id" = i32, Path, description = "Family ID")
),
responses(
(status = 200, description = "All shopping items cleared", body = BulkOperationResponse),
(status = 500, description = "Internal server error")
)
)]
pub async fn clear_all(
State(db): State<DatabaseConnection>,
Path(family_id): Path<i32>,
) -> Result<Json<BulkOperationResponse>, StatusCode> {
ShoppingItemService::clear_all(&db, family_id)
.await
.map(|result| Json(BulkOperationResponse { affected_rows: result.rows_affected }))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

View File

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

View File

@@ -0,0 +1,100 @@
use sea_orm::*;
use sea_orm::prelude::Expr;
use crate::models::shopping_item::{self, Entity as ShoppingItem, Model as ShoppingItemModel};
pub struct ShoppingItemService;
impl ShoppingItemService {
pub async fn create(
db: &DatabaseConnection,
family_id: i32,
name: String,
) -> Result<ShoppingItemModel, DbErr> {
let shopping_item = shopping_item::ActiveModel {
family_id: Set(family_id),
name: Set(name),
is_purchased: Set(false),
..Default::default()
};
shopping_item.insert(db).await
}
pub async fn find_by_id(
db: &DatabaseConnection,
id: i32,
) -> Result<Option<ShoppingItemModel>, DbErr> {
ShoppingItem::find_by_id(id).one(db).await
}
pub async fn find_by_family(
db: &DatabaseConnection,
family_id: i32,
) -> Result<Vec<ShoppingItemModel>, DbErr> {
ShoppingItem::find()
.filter(shopping_item::Column::FamilyId.eq(family_id))
.all(db)
.await
}
pub async fn update(
db: &DatabaseConnection,
id: i32,
name: String,
) -> Result<ShoppingItemModel, DbErr> {
let shopping_item = ShoppingItem::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Shopping item not found".to_string()))?;
let mut shopping_item: shopping_item::ActiveModel = shopping_item.into();
shopping_item.name = Set(name);
shopping_item.update(db).await
}
pub async fn delete(db: &DatabaseConnection, id: i32) -> Result<DeleteResult, DbErr> {
let shopping_item = ShoppingItem::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Shopping item not found".to_string()))?;
let shopping_item: shopping_item::ActiveModel = shopping_item.into();
shopping_item.delete(db).await
}
pub async fn mark_as_purchased(
db: &DatabaseConnection,
id: i32,
is_purchased: bool,
) -> Result<ShoppingItemModel, DbErr> {
let shopping_item = ShoppingItem::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Shopping item not found".to_string()))?;
let mut shopping_item: shopping_item::ActiveModel = shopping_item.into();
shopping_item.is_purchased = Set(is_purchased);
shopping_item.update(db).await
}
pub async fn mark_all_as_purchased(
db: &DatabaseConnection,
family_id: i32,
) -> Result<u64, DbErr> {
ShoppingItem::update_many()
.filter(shopping_item::Column::FamilyId.eq(family_id))
.col_expr(shopping_item::Column::IsPurchased, Expr::value(true))
.exec(db)
.await
.map(|result| result.rows_affected)
}
pub async fn clear_all(db: &DatabaseConnection, family_id: i32) -> Result<DeleteResult, DbErr> {
ShoppingItem::delete_many()
.filter(shopping_item::Column::FamilyId.eq(family_id))
.exec(db)
.await
}
}