1use std::{collections::HashMap, sync::Arc};
2
3use askama::Template;
4use axum::{
5 Extension, Form, Json,
6 extract::{Path, Query, State},
7 http::StatusCode,
8 response::IntoResponse,
9};
10use serde::{Deserialize, Serialize};
11use server::command::{
12 CmdError, CmdResult, FinanceEntity,
13 account::{GetAccountForManage, ListAccountsForManage, SetAccountTag},
14};
15use sqlx::types::Uuid;
16
17use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
18
19#[derive(Template)]
20#[template(path = "pages/account/manage.html")]
21struct AccountManagePage;
22
23#[derive(Clone)]
24struct AccountTreeEntry {
25 id: Uuid,
26 parent_id: Option<Uuid>,
27 name: String,
28}
29
30#[derive(Clone)]
31struct AccountTreeNodeView {
32 id: Uuid,
33 name: String,
34 selected: bool,
35 children: Vec<AccountTreeNodeView>,
36}
37
38#[derive(Clone)]
39struct AccountTagView {
40 id: Uuid,
41 tag_name: String,
42 tag_value: String,
43 description: Option<String>,
44}
45
46#[derive(Clone)]
47struct AccountDetailsTemplateView {
48 id: Uuid,
49 parent_id: Option<Uuid>,
50 name: String,
51 tags: Vec<AccountTagView>,
52}
53
54#[derive(Template)]
55#[template(path = "components/account/manage_content.html")]
56struct AccountManageContentTemplate {
57 tree_html: String,
58 details: Option<AccountDetailsTemplateView>,
59 flash_success: Option<String>,
60 flash_error: Option<String>,
61}
62
63pub async fn account_manage_page() -> impl IntoResponse {
64 HtmlTemplate(AccountManagePage {})
65}
66
67#[derive(Serialize)]
68pub struct AccountTreeNode {
69 id: Uuid,
70 parent_id: Option<Uuid>,
71 name: String,
72}
73
74pub async fn account_tree(
75 State(_data): State<Arc<AppState>>,
76 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
77) -> Result<Json<Vec<AccountTreeNode>>, (StatusCode, Json<serde_json::Value>)> {
78 let entries = list_account_entries(jwt_auth.user.id).await.map_err(|e| {
79 let error_response = serde_json::json!({
80 "status": "fail",
81 "message": "Failed to fetch account tree",
82 });
83 log::error!("Failed to fetch account tree: {e:?}");
84 (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
85 })?;
86
87 let tree = entries
88 .into_iter()
89 .map(|entry| AccountTreeNode {
90 id: entry.id,
91 parent_id: entry.parent_id,
92 name: entry.name,
93 })
94 .collect();
95
96 Ok(Json(tree))
97}
98
99#[derive(Deserialize)]
100pub struct AccountDetailsQuery {
101 account: Uuid,
102}
103
104#[derive(Serialize)]
105pub struct AccountDetailsTagView {
106 id: Uuid,
107 name: String,
108 value: String,
109 description: Option<String>,
110}
111
112#[derive(Serialize)]
113pub struct AccountDetailsView {
114 id: Uuid,
115 parent_id: Option<Uuid>,
116 name: String,
117 tags: Vec<AccountDetailsTagView>,
118}
119
120pub async fn account_manage_details(
121 Query(query): Query<AccountDetailsQuery>,
122 State(_data): State<Arc<AppState>>,
123 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
124) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
125 let details = get_account_details(jwt_auth.user.id, query.account)
126 .await
127 .map_err(|e| cmd_error_to_json(e, "Failed to fetch account details", query.account))?;
128
129 Ok(Json(AccountDetailsView {
130 id: details.id,
131 parent_id: details.parent_id,
132 name: details.name,
133 tags: details
134 .tags
135 .into_iter()
136 .map(|tag| AccountDetailsTagView {
137 id: tag.id,
138 name: tag.tag_name,
139 value: tag.tag_value,
140 description: tag.description,
141 })
142 .collect(),
143 })
144 .into_response())
145}
146
147#[derive(Deserialize)]
148pub struct AccountManageViewQuery {
149 account: Option<Uuid>,
150}
151
152pub async fn account_manage_view(
153 Query(query): Query<AccountManageViewQuery>,
154 State(_data): State<Arc<AppState>>,
155 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
156) -> Result<impl IntoResponse, (StatusCode, String)> {
157 let template = build_manage_content(jwt_auth.user.id, query.account, None, None)
158 .await
159 .map_err(|e| {
160 log::error!("Failed to render account manage view: {e:?}");
161 (
162 StatusCode::INTERNAL_SERVER_ERROR,
163 "Failed to load account management view".into(),
164 )
165 })?;
166
167 Ok(HtmlTemplate(template))
168}
169
170#[derive(Deserialize)]
171pub struct SetAccountTagForm {
172 account_id: Uuid,
173 tag_name: String,
174 tag_value: String,
175 description: Option<String>,
176}
177
178#[derive(Serialize)]
179pub struct SetAccountTagResponse {
180 status: &'static str,
181 account_id: Uuid,
182 tag_name: String,
183 tag_value: String,
184}
185
186pub async fn account_manage_set_tag(
187 State(_data): State<Arc<AppState>>,
188 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
189 Json(form): Json<SetAccountTagForm>,
190) -> Result<Json<SetAccountTagResponse>, (StatusCode, Json<serde_json::Value>)> {
191 let normalized = normalize_tag_input(form.tag_name, form.tag_value, form.description)
192 .map_err(|message| json_error(StatusCode::BAD_REQUEST, message))?;
193
194 set_account_tag(
195 jwt_auth.user.id,
196 form.account_id,
197 &normalized.tag_name,
198 &normalized.tag_value,
199 normalized.description,
200 )
201 .await
202 .map_err(|e| cmd_error_to_json(e, "Failed to set account tag", form.account_id))?;
203
204 Ok(Json(SetAccountTagResponse {
205 status: "ok",
206 account_id: form.account_id,
207 tag_name: normalized.tag_name,
208 tag_value: normalized.tag_value,
209 }))
210}
211
212pub async fn account_manage_set_tag_submit(
213 Query(query): Query<AccountManageViewQuery>,
214 State(_data): State<Arc<AppState>>,
215 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
216 Form(form): Form<SetAccountTagForm>,
217) -> Result<impl IntoResponse, (StatusCode, String)> {
218 let selected_account_id = Some(query.account.unwrap_or(form.account_id));
219
220 let render_with = |success: Option<String>, error: Option<String>| async {
221 build_manage_content(jwt_auth.user.id, selected_account_id, success, error)
222 .await
223 .map(HtmlTemplate)
224 .map_err(|e| {
225 log::error!("Failed to render updated account manage view: {e:?}");
226 (
227 StatusCode::INTERNAL_SERVER_ERROR,
228 "Failed to render account management view".to_string(),
229 )
230 })
231 };
232
233 let normalized = match normalize_tag_input(form.tag_name, form.tag_value, form.description) {
234 Ok(normalized) => normalized,
235 Err(message) => return render_with(None, Some(message)).await,
236 };
237
238 match set_account_tag(
239 jwt_auth.user.id,
240 form.account_id,
241 &normalized.tag_name,
242 &normalized.tag_value,
243 normalized.description,
244 )
245 .await
246 {
247 Ok(()) => render_with(Some("Tag saved".to_string()), None).await,
248 Err(e) => {
249 let message = match e {
250 CmdError::Args(msg) => msg,
251 _ => "Failed to set account tag".to_string(),
252 };
253 render_with(None, Some(message)).await
254 }
255 }
256}
257
258#[derive(Deserialize)]
259pub struct DeleteAccountTagForm {
260 account_id: Uuid,
261}
262
263pub async fn account_manage_delete_tag_submit(
264 Path(tag_id): Path<Uuid>,
265 Query(query): Query<AccountManageViewQuery>,
266 State(_data): State<Arc<AppState>>,
267 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
268 Form(form): Form<DeleteAccountTagForm>,
269) -> Result<impl IntoResponse, (StatusCode, String)> {
270 let selected_account_id = Some(query.account.unwrap_or(form.account_id));
271
272 let server_user = server::user::User {
273 id: jwt_auth.user.id,
274 };
275
276 let (success, error) = match server_user.delete_tag(tag_id).await {
277 Ok(()) => (Some("Tag deleted".to_string()), None),
278 Err(e) => {
279 log::error!("Failed to delete tag {tag_id}: {e:?}");
280 (None, Some("Failed to delete tag".to_string()))
281 }
282 };
283
284 let template = build_manage_content(jwt_auth.user.id, selected_account_id, success, error)
285 .await
286 .map_err(|e| {
287 log::error!("Failed to render account manage view after delete: {e:?}");
288 (
289 StatusCode::INTERNAL_SERVER_ERROR,
290 "Failed to render account management view".to_string(),
291 )
292 })?;
293
294 Ok(HtmlTemplate(template))
295}
296
297#[derive(Clone)]
298struct NormalizedTagInput {
299 tag_name: String,
300 tag_value: String,
301 description: Option<String>,
302}
303
304fn normalize_tag_input(
305 tag_name: String,
306 tag_value: String,
307 description: Option<String>,
308) -> Result<NormalizedTagInput, String> {
309 let tag_name = tag_name.trim().to_string();
310 let tag_value = tag_value.trim().to_string();
311
312 if tag_name.is_empty() || tag_value.is_empty() {
313 return Err("Tag name and value cannot be empty".to_string());
314 }
315
316 let description = description.and_then(|text| {
317 if text.trim().is_empty() {
318 None
319 } else {
320 Some(text)
321 }
322 });
323
324 Ok(NormalizedTagInput {
325 tag_name,
326 tag_value,
327 description,
328 })
329}
330
331async fn set_account_tag(
332 user_id: Uuid,
333 account_id: Uuid,
334 tag_name: &str,
335 tag_value: &str,
336 description: Option<String>,
337) -> Result<(), CmdError> {
338 let mut cmd = SetAccountTag::new()
339 .user_id(user_id)
340 .account_id(account_id)
341 .tag_name(tag_name.to_string())
342 .tag_value(tag_value.to_string());
343
344 if let Some(desc) = description {
345 cmd = cmd.description(desc);
346 }
347
348 cmd.run().await.map(|_| ())
349}
350
351async fn build_manage_content(
352 user_id: Uuid,
353 selected_account_id: Option<Uuid>,
354 flash_success: Option<String>,
355 flash_error: Option<String>,
356) -> Result<AccountManageContentTemplate, CmdError> {
357 let accounts = list_account_entries(user_id).await?;
358 if accounts.is_empty() {
359 return Ok(AccountManageContentTemplate {
360 tree_html: String::new(),
361 details: None,
362 flash_success,
363 flash_error,
364 });
365 }
366
367 let account_ids: std::collections::HashSet<Uuid> = accounts.iter().map(|a| a.id).collect();
368 let selected_account_id = selected_account_id
369 .filter(|id| account_ids.contains(id))
370 .unwrap_or(accounts[0].id);
371
372 let tree = build_tree(&accounts, selected_account_id);
373 let tree_html = render_tree_html(&tree);
374
375 let details = match get_account_details(user_id, selected_account_id).await {
376 Ok(details) => Some(details),
377 Err(e) => {
378 log::error!("Failed to load account details for {selected_account_id}: {e:?}");
379 None
380 }
381 };
382
383 Ok(AccountManageContentTemplate {
384 tree_html,
385 details,
386 flash_success,
387 flash_error,
388 })
389}
390
391async fn list_account_entries(user_id: Uuid) -> Result<Vec<AccountTreeEntry>, CmdError> {
392 let result = ListAccountsForManage::new().user_id(user_id).run().await?;
393
394 let Some(CmdResult::TaggedEntities { entities, .. }) = result else {
395 return Err(CmdError::Args("Unexpected command response".to_string()));
396 };
397
398 let mut accounts: Vec<AccountTreeEntry> = entities
399 .into_iter()
400 .filter_map(|(entity, tags)| match entity {
401 FinanceEntity::Account(account) => {
402 let name = extract_name(&tags).unwrap_or_else(|| account.id.to_string());
403 Some(AccountTreeEntry {
404 id: account.id,
405 parent_id: account.parent,
406 name,
407 })
408 }
409 _ => None,
410 })
411 .collect();
412
413 accounts.sort_by(|a, b| {
414 a.name
415 .to_lowercase()
416 .cmp(&b.name.to_lowercase())
417 .then(a.id.cmp(&b.id))
418 });
419
420 Ok(accounts)
421}
422
423async fn get_account_details(
424 user_id: Uuid,
425 account_id: Uuid,
426) -> Result<AccountDetailsTemplateView, CmdError> {
427 let result = GetAccountForManage::new()
428 .user_id(user_id)
429 .account_id(account_id)
430 .run()
431 .await?;
432
433 let Some(CmdResult::TaggedEntities { entities, .. }) = result else {
434 return Err(CmdError::Args("Unexpected command response".to_string()));
435 };
436
437 let Some((FinanceEntity::Account(account), tags)) = entities.into_iter().next() else {
438 return Err(CmdError::Args("Account not found".to_string()));
439 };
440
441 let mut tag_views = extract_tags(tags);
442 tag_views.sort_by(|a, b| {
443 a.tag_name
444 .cmp(&b.tag_name)
445 .then(a.tag_value.cmp(&b.tag_value))
446 .then(a.id.cmp(&b.id))
447 });
448
449 let name = tag_views
450 .iter()
451 .find(|t| t.tag_name == "name")
452 .map_or_else(|| account.id.to_string(), |t| t.tag_value.clone());
453
454 Ok(AccountDetailsTemplateView {
455 id: account.id,
456 parent_id: account.parent,
457 name,
458 tags: tag_views,
459 })
460}
461
462fn build_tree(
463 accounts: &[AccountTreeEntry],
464 selected_account_id: Uuid,
465) -> Vec<AccountTreeNodeView> {
466 let mut by_parent: HashMap<Option<Uuid>, Vec<&AccountTreeEntry>> = HashMap::new();
467 let all_ids: std::collections::HashSet<Uuid> = accounts.iter().map(|a| a.id).collect();
468
469 for account in accounts {
470 by_parent
471 .entry(account.parent_id)
472 .or_default()
473 .push(account);
474 }
475
476 for children in by_parent.values_mut() {
477 children.sort_by(|a, b| {
478 a.name
479 .to_lowercase()
480 .cmp(&b.name.to_lowercase())
481 .then(a.id.cmp(&b.id))
482 });
483 }
484
485 let mut roots: Vec<&AccountTreeEntry> = by_parent.remove(&None).unwrap_or_default();
486
487 let mut orphan_parents: Vec<Uuid> = by_parent
488 .keys()
489 .filter_map(|id| *id)
490 .filter(|id| !all_ids.contains(id))
491 .collect();
492 orphan_parents.sort();
493
494 for orphan_parent in orphan_parents {
495 if let Some(children) = by_parent.remove(&Some(orphan_parent)) {
496 roots.extend(children);
497 }
498 }
499
500 roots
501 .into_iter()
502 .map(|account| build_tree_node(account, selected_account_id, &by_parent))
503 .collect()
504}
505
506fn render_tree_html(nodes: &[AccountTreeNodeView]) -> String {
507 let mut html = String::new();
508 if nodes.is_empty() {
509 return html;
510 }
511
512 html.push_str("<ul class=\"account-tree-root\">");
513 for node in nodes {
514 render_tree_node_html(node, &mut html);
515 }
516 html.push_str("</ul>");
517 html
518}
519
520fn render_tree_node_html(node: &AccountTreeNodeView, out: &mut String) {
521 use std::fmt::Write;
522
523 let selected_class = if node.selected { " selected" } else { "" };
524 let escaped_name = escape_html(&node.name);
525
526 let _ = write!(
527 out,
528 "<li class=\"account-tree-row\">\
529 <button type=\"button\" class=\"account-tree-btn{selected_class}\" \
530 hx-get=\"/api/account/manage/view?account={id}\" \
531 hx-target=\"#account-manage-content\" hx-swap=\"outerHTML\">\
532 {escaped_name}</button>",
533 id = node.id
534 );
535
536 if !node.children.is_empty() {
537 out.push_str("<ul class=\"account-tree-group\">");
538 for child in &node.children {
539 render_tree_node_html(child, out);
540 }
541 out.push_str("</ul>");
542 }
543
544 out.push_str("</li>");
545}
546
547fn escape_html(input: &str) -> String {
548 input
549 .replace('&', "&")
550 .replace('<', "<")
551 .replace('>', ">")
552 .replace('\"', """)
553 .replace('\'', "'")
554}
555
556fn build_tree_node(
557 account: &AccountTreeEntry,
558 selected_account_id: Uuid,
559 by_parent: &HashMap<Option<Uuid>, Vec<&AccountTreeEntry>>,
560) -> AccountTreeNodeView {
561 let children = by_parent
562 .get(&Some(account.id))
563 .map(|children| {
564 children
565 .iter()
566 .map(|child| build_tree_node(child, selected_account_id, by_parent))
567 .collect()
568 })
569 .unwrap_or_default();
570
571 AccountTreeNodeView {
572 id: account.id,
573 name: account.name.clone(),
574 selected: account.id == selected_account_id,
575 children,
576 }
577}
578
579fn extract_name(tags: &HashMap<String, FinanceEntity>) -> Option<String> {
580 tags.get("name").and_then(|entity| match entity {
581 FinanceEntity::Tag(tag) => Some(tag.tag_value.clone()),
582 _ => None,
583 })
584}
585
586fn extract_tags(tags: HashMap<String, FinanceEntity>) -> Vec<AccountTagView> {
587 tags.into_values()
588 .filter_map(|entity| match entity {
589 FinanceEntity::Tag(tag) => Some(AccountTagView {
590 id: tag.id,
591 tag_name: tag.tag_name,
592 tag_value: tag.tag_value,
593 description: tag.description,
594 }),
595 _ => None,
596 })
597 .collect()
598}
599
600fn json_error(status: StatusCode, message: String) -> (StatusCode, Json<serde_json::Value>) {
601 let error_response = serde_json::json!({
602 "status": "fail",
603 "message": message,
604 });
605 (status, Json(error_response))
606}
607
608fn cmd_error_to_json(
609 e: CmdError,
610 default_message: &str,
611 account_id: Uuid,
612) -> (StatusCode, Json<serde_json::Value>) {
613 let (status, message) = match &e {
614 CmdError::Args(msg) => (StatusCode::NOT_FOUND, msg.clone()),
615 _ => (
616 StatusCode::INTERNAL_SERVER_ERROR,
617 default_message.to_string(),
618 ),
619 };
620 log::error!("Account manage error for {account_id}: {e:?}");
621 json_error(status, message)
622}