personal account

This commit is contained in:
arrelin
2026-01-23 12:51:34 +03:00
parent 24bd4aade4
commit b18f69ea62
15 changed files with 688 additions and 12 deletions

View File

@@ -63,6 +63,8 @@ pub use middleware::{require_admin, require_family_access};
routes::invite_link::delete_invite_link,
routes::invite_link::validate_invite_link,
routes::invite_link::join_family_via_invite,
routes::user::leave_family,
routes::user::get_family_members,
),
components(
schemas(
@@ -94,6 +96,8 @@ pub use middleware::{require_admin, require_family_access};
routes::invite_link::InviteLinkResponse,
routes::invite_link::ValidateInviteResponse,
routes::invite_link::JoinFamilyResponse,
routes::user::LeaveFamilyResponse,
routes::user::FamilyMember,
)
),
tags(
@@ -102,7 +106,8 @@ pub use middleware::{require_admin, require_family_access};
(name = "categories", description = "Category management endpoints"),
(name = "expenses", description = "Expense management endpoints"),
(name = "shopping-items", description = "Shopping list management endpoints"),
(name = "invite-links", description = "Family invite link management endpoints")
(name = "invite-links", description = "Family invite link management endpoints"),
(name = "user", description = "User profile management endpoints")
),
info(
title = "Family Budget API",
@@ -154,6 +159,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
.route("/login", post(routes::auth::login))
.route("/logout", post(routes::auth::logout))
.route("/me", get(routes::auth::me))
.route("/me/leave-family", post(routes::user::leave_family))
.route("/my-family", post(routes::family::create_my_family))
.route("/auth/family-login", post(routes::auth::family_login))
.layer(auth_layer.clone())
@@ -193,6 +199,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
.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("/families/:family_id/members", get(routes::user::get_family_members))
.route_layer(axum_middleware::from_fn(middleware::require_family_access))
.layer(session_layer.clone())
.with_state(db.clone());

View File

@@ -5,3 +5,4 @@ pub mod auth;
pub mod shopping_item;
pub mod oauth;
pub mod invite_link;
pub mod user;

View File

@@ -0,0 +1,97 @@
use axum::{
extract::{State, Path},
http::StatusCode,
Json,
};
use axum_login::AuthSession;
use sea_orm::DatabaseConnection;
use serde::Serialize;
use utoipa::ToSchema;
use crate::auth::AuthBackend;
use crate::services::{UserService, FamilyService};
#[derive(Debug, Serialize, ToSchema)]
pub struct LeaveFamilyResponse {
pub family_deleted: bool,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct FamilyMember {
pub id: i32,
pub username: Option<String>,
pub email: Option<String>,
pub is_admin: bool,
}
#[utoipa::path(
post,
path = "/me/leave-family",
tag = "user",
responses(
(status = 200, description = "Left family successfully", body = LeaveFamilyResponse),
(status = 400, description = "User is not in a family"),
(status = 401, description = "Not authenticated")
)
)]
pub async fn leave_family(
auth_session: AuthSession<AuthBackend>,
State(db): State<DatabaseConnection>,
) -> Result<Json<LeaveFamilyResponse>, StatusCode> {
let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?;
let result = UserService::leave_family(&db, user.id)
.await
.map_err(|e| {
if e.to_string().contains("not in a family") {
StatusCode::BAD_REQUEST
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
Ok(Json(LeaveFamilyResponse {
family_deleted: result.family_deleted,
}))
}
#[utoipa::path(
get,
path = "/families/{family_id}/members",
tag = "families",
params(
("family_id" = i32, Path, description = "Family ID")
),
responses(
(status = 200, description = "List of family members", body = Vec<FamilyMember>),
(status = 401, description = "Not authenticated"),
(status = 403, description = "Access denied")
)
)]
pub async fn get_family_members(
auth_session: AuthSession<AuthBackend>,
State(db): State<DatabaseConnection>,
Path(family_id): Path<i32>,
) -> Result<Json<Vec<FamilyMember>>, StatusCode> {
let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?;
if user.family_id != Some(family_id) && !user.is_admin {
return Err(StatusCode::FORBIDDEN);
}
let members = FamilyService::get_members(&db, family_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let response: Vec<FamilyMember> = members
.into_iter()
.map(|m| FamilyMember {
id: m.id,
username: m.username,
email: m.email,
is_admin: m.is_admin,
})
.collect();
Ok(Json(response))
}

View File

@@ -4,6 +4,7 @@ use argon2::{
Argon2,
};
use crate::models::family::{self, Entity as Family, Model as FamilyModel};
use crate::models::user::{self, Entity as User, Model as UserModel};
pub struct FamilyService;
@@ -72,4 +73,11 @@ impl FamilyService {
let family: family::ActiveModel = family.into();
family.delete(db).await
}
pub async fn get_members(db: &DatabaseConnection, family_id: i32) -> Result<Vec<UserModel>, DbErr> {
User::find()
.filter(user::Column::FamilyId.eq(family_id))
.all(db)
.await
}
}

View File

@@ -4,6 +4,7 @@ pub mod expense_service;
pub mod shopping_item_service;
pub mod oauth_service;
pub mod invite_link_service;
pub mod user_service;
pub use family_service::FamilyService;
pub use category_service::CategoryService;
@@ -11,3 +12,4 @@ pub use expense_service::ExpenseService;
pub use shopping_item_service::ShoppingItemService;
pub use oauth_service::OAuthService;
pub use invite_link_service::InviteLinkService;
pub use user_service::UserService;

View File

@@ -0,0 +1,44 @@
use sea_orm::*;
use crate::models::user::{self, Entity as User, Model as UserModel};
use crate::models::family::{Entity as Family};
pub struct UserService;
#[derive(Debug)]
pub struct LeaveFamilyResult {
pub family_deleted: bool,
}
impl UserService {
pub async fn leave_family(db: &DatabaseConnection, user_id: i32) -> Result<LeaveFamilyResult, DbErr> {
let user = User::find_by_id(user_id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("User not found".to_string()))?;
let family_id = user.family_id
.ok_or(DbErr::Custom("User is not in a family".to_string()))?;
let mut user_active: user::ActiveModel = user.into();
user_active.family_id = Set(None);
user_active.update(db).await?;
let remaining_members = User::find()
.filter(user::Column::FamilyId.eq(family_id))
.count(db)
.await?;
let family_deleted = if remaining_members == 0 {
Family::delete_by_id(family_id).exec(db).await?;
true
} else {
false
};
Ok(LeaveFamilyResult { family_deleted })
}
pub async fn find_by_id(db: &DatabaseConnection, id: i32) -> Result<Option<UserModel>, DbErr> {
User::find_by_id(id).one(db).await
}
}