Lines
20.69 %
Functions
4.75 %
Branches
100 %
use std::{collections::HashMap, sync::Arc};
use askama::Template;
use axum::{
Extension, Form, Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use server::command::{
CmdError, CmdResult, FinanceEntity,
account::{GetAccountForManage, ListAccountsForManage, SetAccountTag},
use sqlx::types::Uuid;
use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
#[derive(Template)]
#[template(path = "pages/account/manage.html")]
struct AccountManagePage;
#[derive(Clone)]
struct AccountTreeEntry {
id: Uuid,
parent_id: Option<Uuid>,
name: String,
}
struct AccountTreeNodeView {
selected: bool,
children: Vec<AccountTreeNodeView>,
struct AccountTagView {
tag_name: String,
tag_value: String,
description: Option<String>,
struct AccountDetailsTemplateView {
tags: Vec<AccountTagView>,
#[template(path = "components/account/manage_content.html")]
struct AccountManageContentTemplate {
tree_html: String,
details: Option<AccountDetailsTemplateView>,
flash_success: Option<String>,
flash_error: Option<String>,
pub async fn account_manage_page() -> impl IntoResponse {
HtmlTemplate(AccountManagePage {})
#[derive(Serialize)]
pub struct AccountTreeNode {
pub async fn account_tree(
State(_data): State<Arc<AppState>>,
Extension(jwt_auth): Extension<JWTAuthMiddleware>,
) -> Result<Json<Vec<AccountTreeNode>>, (StatusCode, Json<serde_json::Value>)> {
let entries = list_account_entries(jwt_auth.user.id).await.map_err(|e| {
let error_response = serde_json::json!({
"status": "fail",
"message": "Failed to fetch account tree",
});
log::error!("Failed to fetch account tree: {e:?}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
})?;
let tree = entries
.into_iter()
.map(|entry| AccountTreeNode {
id: entry.id,
parent_id: entry.parent_id,
name: entry.name,
})
.collect();
Ok(Json(tree))
#[derive(Deserialize)]
pub struct AccountDetailsQuery {
account: Uuid,
pub struct AccountDetailsTagView {
value: String,
pub struct AccountDetailsView {
tags: Vec<AccountDetailsTagView>,
pub async fn account_manage_details(
Query(query): Query<AccountDetailsQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let details = get_account_details(jwt_auth.user.id, query.account)
.await
.map_err(|e| cmd_error_to_json(e, "Failed to fetch account details", query.account))?;
Ok(Json(AccountDetailsView {
id: details.id,
parent_id: details.parent_id,
name: details.name,
tags: details
.tags
.map(|tag| AccountDetailsTagView {
id: tag.id,
name: tag.tag_name,
value: tag.tag_value,
description: tag.description,
.collect(),
.into_response())
pub struct AccountManageViewQuery {
account: Option<Uuid>,
pub async fn account_manage_view(
Query(query): Query<AccountManageViewQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let template = build_manage_content(jwt_auth.user.id, query.account, None, None)
.map_err(|e| {
log::error!("Failed to render account manage view: {e:?}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load account management view".into(),
)
Ok(HtmlTemplate(template))
pub struct SetAccountTagForm {
account_id: Uuid,
pub struct SetAccountTagResponse {
status: &'static str,
pub async fn account_manage_set_tag(
Json(form): Json<SetAccountTagForm>,
) -> Result<Json<SetAccountTagResponse>, (StatusCode, Json<serde_json::Value>)> {
let normalized = normalize_tag_input(form.tag_name, form.tag_value, form.description)
.map_err(|message| json_error(StatusCode::BAD_REQUEST, message))?;
set_account_tag(
jwt_auth.user.id,
form.account_id,
&normalized.tag_name,
&normalized.tag_value,
normalized.description,
.map_err(|e| cmd_error_to_json(e, "Failed to set account tag", form.account_id))?;
Ok(Json(SetAccountTagResponse {
status: "ok",
account_id: form.account_id,
tag_name: normalized.tag_name,
tag_value: normalized.tag_value,
}))
pub async fn account_manage_set_tag_submit(
Form(form): Form<SetAccountTagForm>,
let selected_account_id = Some(query.account.unwrap_or(form.account_id));
let render_with = |success: Option<String>, error: Option<String>| async {
build_manage_content(jwt_auth.user.id, selected_account_id, success, error)
.map(HtmlTemplate)
log::error!("Failed to render updated account manage view: {e:?}");
"Failed to render account management view".to_string(),
let normalized = match normalize_tag_input(form.tag_name, form.tag_value, form.description) {
Ok(normalized) => normalized,
Err(message) => return render_with(None, Some(message)).await,
match set_account_tag(
{
Ok(()) => render_with(Some("Tag saved".to_string()), None).await,
Err(e) => {
let message = match e {
CmdError::Args(msg) => msg,
_ => "Failed to set account tag".to_string(),
render_with(None, Some(message)).await
pub struct DeleteAccountTagForm {
pub async fn account_manage_delete_tag_submit(
Path(tag_id): Path<Uuid>,
Form(form): Form<DeleteAccountTagForm>,
let server_user = server::user::User {
id: jwt_auth.user.id,
let (success, error) = match server_user.delete_tag(tag_id).await {
Ok(()) => (Some("Tag deleted".to_string()), None),
log::error!("Failed to delete tag {tag_id}: {e:?}");
(None, Some("Failed to delete tag".to_string()))
let template = build_manage_content(jwt_auth.user.id, selected_account_id, success, error)
log::error!("Failed to render account manage view after delete: {e:?}");
struct NormalizedTagInput {
fn normalize_tag_input(
) -> Result<NormalizedTagInput, String> {
let tag_name = tag_name.trim().to_string();
let tag_value = tag_value.trim().to_string();
if tag_name.is_empty() || tag_value.is_empty() {
return Err("Tag name and value cannot be empty".to_string());
let description = description.and_then(|text| {
if text.trim().is_empty() {
None
} else {
Some(text)
Ok(NormalizedTagInput {
tag_name,
tag_value,
description,
async fn set_account_tag(
user_id: Uuid,
tag_name: &str,
tag_value: &str,
) -> Result<(), CmdError> {
let mut cmd = SetAccountTag::new()
.user_id(user_id)
.account_id(account_id)
.tag_name(tag_name.to_string())
.tag_value(tag_value.to_string());
if let Some(desc) = description {
cmd = cmd.description(desc);
cmd.run().await.map(|_| ())
async fn build_manage_content(
selected_account_id: Option<Uuid>,
) -> Result<AccountManageContentTemplate, CmdError> {
let accounts = list_account_entries(user_id).await?;
if accounts.is_empty() {
return Ok(AccountManageContentTemplate {
tree_html: String::new(),
details: None,
flash_success,
flash_error,
let account_ids: std::collections::HashSet<Uuid> = accounts.iter().map(|a| a.id).collect();
let selected_account_id = selected_account_id
.filter(|id| account_ids.contains(id))
.unwrap_or(accounts[0].id);
let tree = build_tree(&accounts, selected_account_id);
let tree_html = render_tree_html(&tree);
let details = match get_account_details(user_id, selected_account_id).await {
Ok(details) => Some(details),
log::error!("Failed to load account details for {selected_account_id}: {e:?}");
Ok(AccountManageContentTemplate {
tree_html,
details,
async fn list_account_entries(user_id: Uuid) -> Result<Vec<AccountTreeEntry>, CmdError> {
let result = ListAccountsForManage::new().user_id(user_id).run().await?;
let Some(CmdResult::TaggedEntities { entities, .. }) = result else {
return Err(CmdError::Args("Unexpected command response".to_string()));
let mut accounts: Vec<AccountTreeEntry> = entities
.filter_map(|(entity, tags)| match entity {
FinanceEntity::Account(account) => {
let name = extract_name(&tags).unwrap_or_else(|| account.id.to_string());
Some(AccountTreeEntry {
id: account.id,
parent_id: account.parent,
name,
_ => None,
accounts.sort_by(|a, b| {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then(a.id.cmp(&b.id))
Ok(accounts)
async fn get_account_details(
) -> Result<AccountDetailsTemplateView, CmdError> {
let result = GetAccountForManage::new()
.run()
.await?;
let Some((FinanceEntity::Account(account), tags)) = entities.into_iter().next() else {
return Err(CmdError::Args("Account not found".to_string()));
let mut tag_views = extract_tags(tags);
tag_views.sort_by(|a, b| {
a.tag_name
.cmp(&b.tag_name)
.then(a.tag_value.cmp(&b.tag_value))
let name = tag_views
.iter()
.find(|t| t.tag_name == "name")
.map(|t| t.tag_value.clone())
.unwrap_or_else(|| account.id.to_string());
Ok(AccountDetailsTemplateView {
tags: tag_views,
fn build_tree(
accounts: &[AccountTreeEntry],
selected_account_id: Uuid,
) -> Vec<AccountTreeNodeView> {
let mut by_parent: HashMap<Option<Uuid>, Vec<&AccountTreeEntry>> = HashMap::new();
let all_ids: std::collections::HashSet<Uuid> = accounts.iter().map(|a| a.id).collect();
for account in accounts {
by_parent
.entry(account.parent_id)
.or_default()
.push(account);
for children in by_parent.values_mut() {
children.sort_by(|a, b| {
let mut roots: Vec<&AccountTreeEntry> = by_parent.remove(&None).unwrap_or_default();
let mut orphan_parents: Vec<Uuid> = by_parent
.keys()
.filter_map(|id| *id)
.filter(|id| !all_ids.contains(id))
orphan_parents.sort();
for orphan_parent in orphan_parents {
if let Some(children) = by_parent.remove(&Some(orphan_parent)) {
roots.extend(children);
roots
.map(|account| build_tree_node(account, selected_account_id, &by_parent))
.collect()
fn render_tree_html(nodes: &[AccountTreeNodeView]) -> String {
let mut html = String::new();
if nodes.is_empty() {
return html;
html.push_str("<ul class=\"account-tree-root\">");
for node in nodes {
render_tree_node_html(node, &mut html);
html.push_str("</ul>");
html
fn render_tree_node_html(node: &AccountTreeNodeView, out: &mut String) {
use std::fmt::Write;
let selected_class = if node.selected { " selected" } else { "" };
let escaped_name = escape_html(&node.name);
let _ = write!(
out,
"<li class=\"account-tree-row\">\
<button type=\"button\" class=\"account-tree-btn{selected_class}\" \
hx-get=\"/api/account/manage/view?account={id}\" \
hx-target=\"#account-manage-content\" hx-swap=\"outerHTML\">\
{escaped_name}</button>",
id = node.id
);
if !node.children.is_empty() {
out.push_str("<ul class=\"account-tree-group\">");
for child in &node.children {
render_tree_node_html(child, out);
out.push_str("</ul>");
out.push_str("</li>");
fn escape_html(input: &str) -> String {
input
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('\"', """)
.replace('\'', "'")
fn build_tree_node(
account: &AccountTreeEntry,
by_parent: &HashMap<Option<Uuid>, Vec<&AccountTreeEntry>>,
) -> AccountTreeNodeView {
let children = by_parent
.get(&Some(account.id))
.map(|children| {
children
.map(|child| build_tree_node(child, selected_account_id, by_parent))
.unwrap_or_default();
AccountTreeNodeView {
name: account.name.clone(),
selected: account.id == selected_account_id,
children,
fn extract_name(tags: &HashMap<String, FinanceEntity>) -> Option<String> {
tags.get("name").and_then(|entity| match entity {
FinanceEntity::Tag(tag) => Some(tag.tag_value.clone()),
fn extract_tags(tags: HashMap<String, FinanceEntity>) -> Vec<AccountTagView> {
tags.into_values()
.filter_map(|entity| match entity {
FinanceEntity::Tag(tag) => Some(AccountTagView {
tag_name: tag.tag_name,
tag_value: tag.tag_value,
}),
fn json_error(status: StatusCode, message: String) -> (StatusCode, Json<serde_json::Value>) {
"message": message,
(status, Json(error_response))
fn cmd_error_to_json(
e: CmdError,
default_message: &str,
) -> (StatusCode, Json<serde_json::Value>) {
let (status, message) = match &e {
CmdError::Args(msg) => (StatusCode::NOT_FOUND, msg.clone()),
_ => (
default_message.to_string(),
),
log::error!("Account manage error for {account_id}: {e:?}");
json_error(status, message)