From c96595d41ef7d9a0c92e0627a8f7dca3a51aecae Mon Sep 17 00:00:00 2001 From: juancwu Date: Sun, 3 May 2026 23:50:39 +0000 Subject: [PATCH] feat: transaction activity audit and account activity audit --- internal/app/app.go | 9 + ...10_create_transaction_audit_logs_table.sql | 19 ++ internal/handler/space.go | 201 ++++++++++++++- internal/model/account_activity.go | 18 ++ internal/model/space_audit_log.go | 3 + internal/model/transaction_audit_log.go | 26 ++ internal/repository/space_audit_log.go | 30 +++ internal/repository/transaction_audit_log.go | 90 +++++++ internal/routes/routes.go | 4 +- internal/service/account.go | 52 +++- internal/service/account_activity.go | 77 ++++++ internal/service/auth.go | 2 +- internal/service/transaction.go | 110 ++++++++ internal/service/transaction_audit_log.go | 78 ++++++ .../ui/pages/space_account_activity.templ | 238 ++++++++++++++++++ internal/ui/pages/space_activity.templ | 34 +++ internal/ui/pages/space_overview.templ | 10 + internal/ui/pages/space_transaction.templ | 43 +++- .../ui/pages/space_transaction_activity.templ | 235 +++++++++++++++++ 19 files changed, 1259 insertions(+), 20 deletions(-) create mode 100644 internal/db/migrations/00010_create_transaction_audit_logs_table.sql create mode 100644 internal/model/account_activity.go create mode 100644 internal/model/transaction_audit_log.go create mode 100644 internal/repository/transaction_audit_log.go create mode 100644 internal/service/account_activity.go create mode 100644 internal/service/transaction_audit_log.go create mode 100644 internal/ui/pages/space_account_activity.templ create mode 100644 internal/ui/pages/space_transaction_activity.templ diff --git a/internal/app/app.go b/internal/app/app.go index 8e7ad42..4ca8598 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -21,6 +21,8 @@ type App struct { TransactionService *service.TransactionService InviteService *service.InviteService AuditLogService *service.SpaceAuditLogService + TxAuditLogService *service.TransactionAuditLogService + AccountActivitySvc *service.AccountActivityService } func New(cfg *config.Config) (*App, error) { @@ -45,14 +47,19 @@ func New(cfg *config.Config) (*App, error) { categoryRepository := repository.NewCategoryRepository(database) invitationRepository := repository.NewInvitationRepository(database) auditLogRepository := repository.NewSpaceAuditLogRepository(database) + txAuditLogRepository := repository.NewTransactionAuditLogRepository(database) // Services userService := service.NewUserService(userRepository) auditLogService := service.NewSpaceAuditLogService(auditLogRepository) + txAuditLogService := service.NewTransactionAuditLogService(txAuditLogRepository) spaceService := service.NewSpaceService(spaceRepository) spaceService.SetAuditLogger(auditLogService) accountService := service.NewAccountService(accountRepository) + accountService.SetAuditLogger(auditLogService) transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService) + transactionService.SetAuditLogger(txAuditLogService) + accountActivityService := service.NewAccountActivityService(auditLogService, txAuditLogService) emailService := service.NewEmailService( emailClient, cfg.MailerEmailFrom, @@ -84,6 +91,8 @@ func New(cfg *config.Config) (*App, error) { TransactionService: transactionService, InviteService: inviteService, AuditLogService: auditLogService, + TxAuditLogService: txAuditLogService, + AccountActivitySvc: accountActivityService, }, nil } diff --git a/internal/db/migrations/00010_create_transaction_audit_logs_table.sql b/internal/db/migrations/00010_create_transaction_audit_logs_table.sql new file mode 100644 index 0000000..0387f95 --- /dev/null +++ b/internal/db/migrations/00010_create_transaction_audit_logs_table.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE transaction_audit_logs ( + id TEXT PRIMARY KEY NOT NULL, + transaction_id TEXT NOT NULL, + actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_transaction_audit_logs_transaction_id_created_at + ON transaction_audit_logs (transaction_id, created_at DESC); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE transaction_audit_logs; +-- +goose StatementEnd diff --git a/internal/handler/space.go b/internal/handler/space.go index 7737ce0..5f10b13 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -26,6 +26,8 @@ type spaceHandler struct { transactionService *service.TransactionService inviteService *service.InviteService auditLogService *service.SpaceAuditLogService + txAuditLogService *service.TransactionAuditLogService + accountActivitySvc *service.AccountActivityService } func NewSpaceHandler( @@ -34,6 +36,8 @@ func NewSpaceHandler( transactionService *service.TransactionService, inviteService *service.InviteService, auditLogService *service.SpaceAuditLogService, + txAuditLogService *service.TransactionAuditLogService, + accountActivitySvc *service.AccountActivityService, ) *spaceHandler { return &spaceHandler{ spaceService: spaceService, @@ -41,6 +45,8 @@ func NewSpaceHandler( transactionService: transactionService, inviteService: inviteService, auditLogService: auditLogService, + txAuditLogService: txAuditLogService, + accountActivitySvc: accountActivitySvc, } } @@ -271,7 +277,12 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques } } - account, err := h.accountService.CreateAccount(spaceID, nameInput) + user := ctxkeys.User(r.Context()) + actorID := "" + if user != nil { + actorID = user.ID + } + account, err := h.accountService.CreateAccount(spaceID, nameInput, actorID) if err != nil { slog.Error("failed to create account", "error", err, "space_id", spaceID) formProps.GeneralErr = "Something went wrong. Please try again." @@ -776,7 +787,12 @@ func (h *spaceHandler) HandleRenameAccount(w http.ResponseWriter, r *http.Reques } } - if err := h.accountService.RenameAccount(accountID, nameInput); err != nil { + user := ctxkeys.User(r.Context()) + actorID := "" + if user != nil { + actorID = user.ID + } + if err := h.accountService.RenameAccount(accountID, nameInput, actorID); err != nil { slog.Error("failed to rename account", "error", err, "account_id", accountID) formProps.GeneralErr = "Something went wrong. Please try again." ui.Render(w, r, forms.UpdateAccount(formProps)) @@ -797,7 +813,12 @@ func (h *spaceHandler) HandleDeleteAccount(w http.ResponseWriter, r *http.Reques return } - if err := h.accountService.DeleteAccount(accountID); err != nil { + user := ctxkeys.User(r.Context()) + actorID := "" + if user != nil { + actorID = user.ID + } + if err := h.accountService.DeleteAccount(accountID, actorID); err != nil { slog.Error("failed to delete account", "error", err, "account_id", accountID) ui.RenderError(w, r, "Failed to delete account", http.StatusInternalServerError) return @@ -949,12 +970,17 @@ func (h *spaceHandler) HandleCreateDeposit(w http.ResponseWriter, r *http.Reques return } + actorID := "" + if u := ctxkeys.User(r.Context()); u != nil { + actorID = u.ID + } _, err := h.transactionService.Deposit(service.DepositInput{ AccountID: accountID, Title: titleInput, Amount: amount, OccurredAt: occurredAt, Description: descriptionInput, + ActorID: actorID, }) if err != nil { slog.Error("failed to create deposit", "error", err, "account_id", accountID) @@ -1016,13 +1042,155 @@ func (h *spaceHandler) SpaceTransactionPage(w http.ResponseWriter, r *http.Reque } } + recentLogs, err := h.txAuditLogService.List(transactionID, 5, 0) + if err != nil { + slog.Error("failed to load transaction audit logs", "error", err, "transaction_id", transactionID) + recentLogs = nil + } + logCount, err := h.txAuditLogService.Count(transactionID) + if err != nil { + slog.Error("failed to count transaction audit logs", "error", err, "transaction_id", transactionID) + logCount = len(recentLogs) + } + ui.Render(w, r, pages.SpaceTransactionPage(pages.SpaceTransactionPageProps{ - SpaceID: spaceID, - SpaceName: space.Name, - AccountID: accountID, - AccountName: account.Name, - Transaction: txn, - CategoryName: categoryName, + SpaceID: spaceID, + SpaceName: space.Name, + AccountID: accountID, + AccountName: account.Name, + Transaction: txn, + CategoryName: categoryName, + RecentAuditLogs: recentLogs, + AuditLogCount: logCount, + })) +} + +func (h *spaceHandler) SpaceAccountActivityPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.Render(w, r, pages.NotFound()) + return + } + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + slog.Error("failed to load space", "error", err, "space_id", spaceID) + ui.RenderError(w, r, "Failed to load activity", http.StatusInternalServerError) + return + } + + const perPage = 25 + page := 1 + if p := strings.TrimSpace(r.URL.Query().Get("page")); p != "" { + if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 { + page = parsed + } + } + + total, err := h.accountActivitySvc.Count(accountID) + if err != nil { + slog.Error("failed to count account activity", "error", err, "account_id", accountID) + ui.RenderError(w, r, "Failed to load activity", http.StatusInternalServerError) + return + } + + totalPages := (total + perPage - 1) / perPage + if totalPages < 1 { + totalPages = 1 + } + if page > totalPages { + page = totalPages + } + + rows, err := h.accountActivitySvc.List(accountID, perPage, (page-1)*perPage) + if err != nil { + slog.Error("failed to list account activity", "error", err, "account_id", accountID) + ui.RenderError(w, r, "Failed to load activity", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.SpaceAccountActivityPage(pages.SpaceAccountActivityPageProps{ + SpaceID: spaceID, + SpaceName: space.Name, + AccountID: accountID, + AccountName: account.Name, + Rows: rows, + CurrentPage: page, + TotalPages: totalPages, + TotalCount: total, + PerPage: perPage, + })) +} + +func (h *spaceHandler) SpaceTransactionActivityPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + transactionID := r.PathValue("transactionID") + + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.Render(w, r, pages.NotFound()) + return + } + + txn, err := h.transactionService.GetTransaction(transactionID) + if err != nil || txn.AccountID != accountID { + ui.Render(w, r, pages.NotFound()) + return + } + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + slog.Error("failed to load space", "error", err, "space_id", spaceID) + ui.RenderError(w, r, "Failed to load activity", http.StatusInternalServerError) + return + } + + const perPage = 25 + page := 1 + if p := strings.TrimSpace(r.URL.Query().Get("page")); p != "" { + if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 { + page = parsed + } + } + + total, err := h.txAuditLogService.Count(transactionID) + if err != nil { + slog.Error("failed to count transaction audit logs", "error", err, "transaction_id", transactionID) + ui.RenderError(w, r, "Failed to load activity", http.StatusInternalServerError) + return + } + + totalPages := (total + perPage - 1) / perPage + if totalPages < 1 { + totalPages = 1 + } + if page > totalPages { + page = totalPages + } + + logs, err := h.txAuditLogService.List(transactionID, perPage, (page-1)*perPage) + if err != nil { + slog.Error("failed to list transaction audit logs", "error", err, "transaction_id", transactionID) + ui.RenderError(w, r, "Failed to load activity", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.SpaceTransactionActivityPage(pages.SpaceTransactionActivityPageProps{ + SpaceID: spaceID, + SpaceName: space.Name, + AccountID: accountID, + AccountName: account.Name, + TransactionID: transactionID, + TransactionName: txn.Title, + Logs: logs, + CurrentPage: page, + TotalPages: totalPages, + TotalCount: total, + PerPage: perPage, })) } @@ -1182,12 +1350,17 @@ func (h *spaceHandler) HandleEditTransaction(w http.ResponseWriter, r *http.Requ ui.Render(w, r, forms.EditDeposit(formProps)) return } + actorID := "" + if u := ctxkeys.User(r.Context()); u != nil { + actorID = u.ID + } if _, err := h.transactionService.UpdateDeposit(service.UpdateDepositInput{ TransactionID: transactionID, Title: titleInput, Amount: amount, OccurredAt: occurredAt, Description: descriptionInput, + ActorID: actorID, }); err != nil { slog.Error("failed to update deposit", "error", err, "transaction_id", transactionID) formProps.GeneralErr = "Something went wrong. Please try again." @@ -1230,6 +1403,10 @@ func (h *spaceHandler) HandleEditTransaction(w http.ResponseWriter, r *http.Requ ui.Render(w, r, forms.EditBill(formProps)) return } + actorID := "" + if u := ctxkeys.User(r.Context()); u != nil { + actorID = u.ID + } if _, err := h.transactionService.UpdateBill(service.UpdateBillInput{ TransactionID: transactionID, Title: titleInput, @@ -1237,6 +1414,7 @@ func (h *spaceHandler) HandleEditTransaction(w http.ResponseWriter, r *http.Requ OccurredAt: occurredAt, Description: descriptionInput, CategoryID: categoryInput, + ActorID: actorID, }); err != nil { slog.Error("failed to update bill", "error", err, "transaction_id", transactionID) formProps.GeneralErr = "Something went wrong. Please try again." @@ -1326,6 +1504,10 @@ func (h *spaceHandler) HandleCreateBill(w http.ResponseWriter, r *http.Request) return } + actorID := "" + if u := ctxkeys.User(r.Context()); u != nil { + actorID = u.ID + } _, err = h.transactionService.PayBill(service.PayBillInput{ AccountID: accountID, Title: titleInput, @@ -1333,6 +1515,7 @@ func (h *spaceHandler) HandleCreateBill(w http.ResponseWriter, r *http.Request) OccurredAt: occurredAt, Description: descriptionInput, CategoryID: categoryInput, + ActorID: actorID, }) if err != nil { slog.Error("failed to create bill", "error", err, "account_id", accountID) diff --git a/internal/model/account_activity.go b/internal/model/account_activity.go new file mode 100644 index 0000000..6469013 --- /dev/null +++ b/internal/model/account_activity.go @@ -0,0 +1,18 @@ +package model + +import "time" + +// AccountActivityRow is a unified row representing either an account-scoped space +// audit entry or a transaction audit entry that belongs to the account. Exactly one +// of SpaceLog / TxLog is set. +type AccountActivityRow struct { + SpaceLog *SpaceAuditLogWithActor + TxLog *TransactionAuditLogWithActor +} + +func (r AccountActivityRow) Timestamp() time.Time { + if r.SpaceLog != nil { + return r.SpaceLog.CreatedAt + } + return r.TxLog.CreatedAt +} diff --git a/internal/model/space_audit_log.go b/internal/model/space_audit_log.go index 3037912..3eb45f6 100644 --- a/internal/model/space_audit_log.go +++ b/internal/model/space_audit_log.go @@ -11,6 +11,9 @@ const ( SpaceAuditActionMemberJoined SpaceAuditAction = "member.joined" SpaceAuditActionMemberRemoved SpaceAuditAction = "member.removed" SpaceAuditActionInviteCancelled SpaceAuditAction = "invite.cancelled" + SpaceAuditActionAccountCreated SpaceAuditAction = "account.created" + SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed" + SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted" ) type SpaceAuditLog struct { diff --git a/internal/model/transaction_audit_log.go b/internal/model/transaction_audit_log.go new file mode 100644 index 0000000..013f310 --- /dev/null +++ b/internal/model/transaction_audit_log.go @@ -0,0 +1,26 @@ +package model + +import "time" + +type TransactionAuditAction string + +const ( + TransactionAuditActionCreated TransactionAuditAction = "transaction.created" + TransactionAuditActionEdited TransactionAuditAction = "transaction.edited" + TransactionAuditActionDeleted TransactionAuditAction = "transaction.deleted" +) + +type TransactionAuditLog struct { + ID string `db:"id"` + TransactionID string `db:"transaction_id"` + ActorID *string `db:"actor_id"` + Action TransactionAuditAction `db:"action"` + Metadata []byte `db:"metadata"` + CreatedAt time.Time `db:"created_at"` +} + +type TransactionAuditLogWithActor struct { + TransactionAuditLog + ActorName *string `db:"actor_name"` + ActorEmail *string `db:"actor_email"` +} diff --git a/internal/repository/space_audit_log.go b/internal/repository/space_audit_log.go index 5176dbb..1941b08 100644 --- a/internal/repository/space_audit_log.go +++ b/internal/repository/space_audit_log.go @@ -9,6 +9,8 @@ type SpaceAuditLogRepository interface { Create(log *model.SpaceAuditLog) error ListBySpace(spaceID string, limit, offset int) ([]*model.SpaceAuditLogWithActor, error) CountBySpace(spaceID string) (int, error) + ListAccountEvents(accountID string, limit, offset int) ([]*model.SpaceAuditLogWithActor, error) + CountAccountEvents(accountID string) (int, error) } type spaceAuditLogRepository struct { @@ -58,3 +60,31 @@ func (r *spaceAuditLogRepository) CountBySpace(spaceID string) (int, error) { err := r.db.Get(&count, `SELECT COUNT(*) FROM space_audit_logs WHERE space_id = $1;`, spaceID) return count, err } + +func (r *spaceAuditLogRepository) ListAccountEvents(accountID string, limit, offset int) ([]*model.SpaceAuditLogWithActor, error) { + query := ` + SELECT + a.id, a.space_id, a.actor_id, a.action, a.target_user_id, a.target_email, + a.metadata, a.created_at, + actor.name AS actor_name, actor.email AS actor_email, + target.name AS target_user_name, target.email AS target_user_email + FROM space_audit_logs a + LEFT JOIN users actor ON actor.id = a.actor_id + LEFT JOIN users target ON target.id = a.target_user_id + WHERE a.action LIKE 'account.%' + AND a.metadata->>'account_id' = $1 + ORDER BY a.created_at DESC + LIMIT $2 OFFSET $3;` + var logs []*model.SpaceAuditLogWithActor + err := r.db.Select(&logs, query, accountID, limit, offset) + return logs, err +} + +func (r *spaceAuditLogRepository) CountAccountEvents(accountID string) (int, error) { + var count int + err := r.db.Get(&count, + `SELECT COUNT(*) FROM space_audit_logs + WHERE action LIKE 'account.%' AND metadata->>'account_id' = $1;`, + accountID) + return count, err +} diff --git a/internal/repository/transaction_audit_log.go b/internal/repository/transaction_audit_log.go new file mode 100644 index 0000000..592dee9 --- /dev/null +++ b/internal/repository/transaction_audit_log.go @@ -0,0 +1,90 @@ +package repository + +import ( + "git.juancwu.dev/juancwu/budgit/internal/model" + "github.com/jmoiron/sqlx" +) + +type TransactionAuditLogRepository interface { + Create(log *model.TransactionAuditLog) error + ListByTransaction(transactionID string, limit, offset int) ([]*model.TransactionAuditLogWithActor, error) + CountByTransaction(transactionID string) (int, error) + ListByAccount(accountID string, limit, offset int) ([]*model.TransactionAuditLogWithActor, error) + CountByAccount(accountID string) (int, error) +} + +type transactionAuditLogRepository struct { + db *sqlx.DB +} + +func NewTransactionAuditLogRepository(db *sqlx.DB) TransactionAuditLogRepository { + return &transactionAuditLogRepository{db: db} +} + +func (r *transactionAuditLogRepository) Create(log *model.TransactionAuditLog) error { + query := ` + INSERT INTO transaction_audit_logs + (id, transaction_id, actor_id, action, metadata, created_at) + VALUES ($1, $2, $3, $4, $5, $6);` + metadata := log.Metadata + if len(metadata) == 0 { + metadata = []byte("{}") + } + _, err := r.db.Exec(query, + log.ID, log.TransactionID, log.ActorID, log.Action, metadata, log.CreatedAt, + ) + return err +} + +func (r *transactionAuditLogRepository) ListByTransaction(transactionID string, limit, offset int) ([]*model.TransactionAuditLogWithActor, error) { + query := ` + SELECT + a.id, a.transaction_id, a.actor_id, a.action, a.metadata, a.created_at, + actor.name AS actor_name, actor.email AS actor_email + FROM transaction_audit_logs a + LEFT JOIN users actor ON actor.id = a.actor_id + WHERE a.transaction_id = $1 + ORDER BY a.created_at DESC + LIMIT $2 OFFSET $3;` + var logs []*model.TransactionAuditLogWithActor + err := r.db.Select(&logs, query, transactionID, limit, offset) + return logs, err +} + +func (r *transactionAuditLogRepository) CountByTransaction(transactionID string) (int, error) { + var count int + err := r.db.Get(&count, `SELECT COUNT(*) FROM transaction_audit_logs WHERE transaction_id = $1;`, transactionID) + return count, err +} + +// ListByAccount returns transaction audit entries whose transaction belongs to the given +// account. Uses the live transactions table when present and falls back to the metadata +// account_id (set on creation) so entries for deleted transactions are still surfaced. +func (r *transactionAuditLogRepository) ListByAccount(accountID string, limit, offset int) ([]*model.TransactionAuditLogWithActor, error) { + query := ` + SELECT + a.id, a.transaction_id, a.actor_id, a.action, a.metadata, a.created_at, + actor.name AS actor_name, actor.email AS actor_email + FROM transaction_audit_logs a + LEFT JOIN users actor ON actor.id = a.actor_id + LEFT JOIN transactions t ON t.id = a.transaction_id + WHERE t.account_id = $1 + OR (t.id IS NULL AND a.metadata->>'account_id' = $1) + ORDER BY a.created_at DESC + LIMIT $2 OFFSET $3;` + var logs []*model.TransactionAuditLogWithActor + err := r.db.Select(&logs, query, accountID, limit, offset) + return logs, err +} + +func (r *transactionAuditLogRepository) CountByAccount(accountID string) (int, error) { + var count int + err := r.db.Get(&count, + `SELECT COUNT(*) + FROM transaction_audit_logs a + LEFT JOIN transactions t ON t.id = a.transaction_id + WHERE t.account_id = $1 + OR (t.id IS NULL AND a.metadata->>'account_id' = $1);`, + accountID) + return count, err +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index e2a2955..b52668e 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -19,7 +19,7 @@ func SetupRoutes(a *app.App) http.Handler { authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService) homeH := handler.NewHomeHandler() settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService) - spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService, a.InviteService, a.AuditLogService) + spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService, a.InviteService, a.AuditLogService, a.TxAuditLogService, a.AccountActivitySvc) redirectH := handler.NewRedirectHandler() r := router.New() @@ -108,10 +108,12 @@ func SetupRoutes(a *app.App) http.Handler { g.SubGroup("/accounts/{accountID}", func(g *router.Group) { g.Get("/overview", spaceH.SpaceAccountPage).Name("page.app.spaces.space.accounts.account.overview") + g.Get("/activity", spaceH.SpaceAccountActivityPage).Name("page.app.spaces.space.accounts.account.activity") g.Get("/transactions", spaceH.SpaceAccountTransactionsPage).Name("page.app.spaces.space.accounts.account.transactions") g.Get("/transactions/{transactionID}", spaceH.SpaceTransactionPage).Name("page.app.spaces.space.accounts.account.transactions.transaction") g.Get("/transactions/{transactionID}/edit", spaceH.SpaceEditTransactionPage).Name("page.app.spaces.space.accounts.account.transactions.transaction.edit") g.Post("/transactions/{transactionID}/edit", spaceH.HandleEditTransaction).Name("action.app.spaces.space.accounts.account.transactions.transaction.edit") + g.Get("/transactions/{transactionID}/activity", spaceH.SpaceTransactionActivityPage).Name("page.app.spaces.space.accounts.account.transactions.transaction.activity") g.Get("/settings", spaceH.SpaceAccountSettingsPage).Name("page.app.spaces.space.accounts.account.settings") g.Post("/settings/rename", spaceH.HandleRenameAccount).Name("action.app.spaces.space.accounts.account.settings.rename") g.Post("/settings/delete", spaceH.HandleDeleteAccount).Name("action.app.spaces.space.accounts.account.settings.delete") diff --git a/internal/service/account.go b/internal/service/account.go index 39e88a5..a1d9ca9 100644 --- a/internal/service/account.go +++ b/internal/service/account.go @@ -13,13 +13,19 @@ const DefaultAccountName = "Money Account" type AccountService struct { accountRepo repository.AccountRepository + auditSvc *SpaceAuditLogService } func NewAccountService(accountRepo repository.AccountRepository) *AccountService { return &AccountService{accountRepo: accountRepo} } -func (s *AccountService) CreateAccount(spaceID, name string) (*model.Account, error) { +// SetAuditLogger wires the audit log service after construction. +func (s *AccountService) SetAuditLogger(audit *SpaceAuditLogService) { + s.auditSvc = audit +} + +func (s *AccountService) CreateAccount(spaceID, name, actorID string) (*model.Account, error) { if spaceID == "" { return nil, fmt.Errorf("space id is required") } @@ -38,6 +44,15 @@ func (s *AccountService) CreateAccount(spaceID, name string) (*model.Account, er if err := s.accountRepo.Create(account); err != nil { return nil, fmt.Errorf("failed to create account: %w", err) } + s.auditSvc.Record(RecordOptions{ + SpaceID: spaceID, + ActorID: actorID, + Action: model.SpaceAuditActionAccountCreated, + Metadata: map[string]any{ + "account_id": account.ID, + "account_name": account.Name, + }, + }) return account, nil } @@ -49,23 +64,54 @@ func (s *AccountService) GetAccount(id string) (*model.Account, error) { return account, nil } -func (s *AccountService) RenameAccount(id, name string) error { +func (s *AccountService) RenameAccount(id, name, actorID string) error { if id == "" { return fmt.Errorf("account id is required") } if name == "" { return fmt.Errorf("account name cannot be empty") } + current, err := s.accountRepo.ByID(id) + if err != nil { + return fmt.Errorf("failed to load account: %w", err) + } + oldName := current.Name if err := s.accountRepo.Rename(id, name); err != nil { return fmt.Errorf("failed to rename account: %w", err) } + if oldName != name { + s.auditSvc.Record(RecordOptions{ + SpaceID: current.SpaceID, + ActorID: actorID, + Action: model.SpaceAuditActionAccountRenamed, + Metadata: map[string]any{ + "account_id": id, + "old_name": oldName, + "new_name": name, + }, + }) + } return nil } -func (s *AccountService) DeleteAccount(id string) error { +func (s *AccountService) DeleteAccount(id, actorID string) error { if id == "" { return fmt.Errorf("account id is required") } + current, err := s.accountRepo.ByID(id) + if err != nil { + return fmt.Errorf("failed to load account: %w", err) + } + // Record before deleting so the audit row references the pre-delete state. + s.auditSvc.Record(RecordOptions{ + SpaceID: current.SpaceID, + ActorID: actorID, + Action: model.SpaceAuditActionAccountDeleted, + Metadata: map[string]any{ + "account_id": id, + "account_name": current.Name, + }, + }) if err := s.accountRepo.Delete(id); err != nil { return fmt.Errorf("failed to delete account: %w", err) } diff --git a/internal/service/account_activity.go b/internal/service/account_activity.go new file mode 100644 index 0000000..95f12e0 --- /dev/null +++ b/internal/service/account_activity.go @@ -0,0 +1,77 @@ +package service + +import ( + "fmt" + "sort" + + "git.juancwu.dev/juancwu/budgit/internal/model" +) + +type AccountActivityService struct { + spaceAudit *SpaceAuditLogService + txAudit *TransactionAuditLogService +} + +func NewAccountActivityService(spaceAudit *SpaceAuditLogService, txAudit *TransactionAuditLogService) *AccountActivityService { + return &AccountActivityService{ + spaceAudit: spaceAudit, + txAudit: txAudit, + } +} + +// List returns a merged feed of account-scoped events (account.created/renamed/deleted) +// and transaction events (created/edited/deleted) for transactions in this account. +// +// Rather than a SQL UNION across two heterogeneous tables, we fetch up to (offset+limit) +// from each side and merge in Go. Audit volume per account is low, so the simplicity +// outweighs the slight overfetch. +func (s *AccountActivityService) List(accountID string, limit, offset int) ([]model.AccountActivityRow, error) { + if limit <= 0 { + limit = 25 + } + if offset < 0 { + offset = 0 + } + fetchN := offset + limit + + spaceLogs, err := s.spaceAudit.repo.ListAccountEvents(accountID, fetchN, 0) + if err != nil { + return nil, fmt.Errorf("failed to list account events: %w", err) + } + txLogs, err := s.txAudit.repo.ListByAccount(accountID, fetchN, 0) + if err != nil { + return nil, fmt.Errorf("failed to list transaction events: %w", err) + } + + rows := make([]model.AccountActivityRow, 0, len(spaceLogs)+len(txLogs)) + for _, l := range spaceLogs { + rows = append(rows, model.AccountActivityRow{SpaceLog: l}) + } + for _, l := range txLogs { + rows = append(rows, model.AccountActivityRow{TxLog: l}) + } + sort.Slice(rows, func(i, j int) bool { + return rows[i].Timestamp().After(rows[j].Timestamp()) + }) + + if offset >= len(rows) { + return nil, nil + } + end := offset + limit + if end > len(rows) { + end = len(rows) + } + return rows[offset:end], nil +} + +func (s *AccountActivityService) Count(accountID string) (int, error) { + spaceCount, err := s.spaceAudit.repo.CountAccountEvents(accountID) + if err != nil { + return 0, fmt.Errorf("failed to count account events: %w", err) + } + txCount, err := s.txAudit.repo.CountByAccount(accountID) + if err != nil { + return 0, fmt.Errorf("failed to count transaction events: %w", err) + } + return spaceCount + txCount, nil +} diff --git a/internal/service/auth.go b/internal/service/auth.go index c69e6fe..9bd6356 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -363,7 +363,7 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error { return fmt.Errorf("failed to create onboarding space: %w", err) } - if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName); err != nil { + if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName, userID); err != nil { if delErr := s.spaceService.DeleteSpace(space.ID, userID); delErr != nil { slog.Error("failed to roll back space after account creation error", "space_id", space.ID, "error", delErr) diff --git a/internal/service/transaction.go b/internal/service/transaction.go index 3a7dc76..e95692e 100644 --- a/internal/service/transaction.go +++ b/internal/service/transaction.go @@ -15,6 +15,7 @@ type TransactionService struct { transactionRepo repository.TransactionRepository categoryRepo repository.CategoryRepository accountService *AccountService + auditSvc *TransactionAuditLogService } func NewTransactionService( @@ -29,6 +30,11 @@ func NewTransactionService( } } +// SetAuditLogger wires the audit log service after construction. +func (s *TransactionService) SetAuditLogger(audit *TransactionAuditLogService) { + s.auditSvc = audit +} + type PayBillInput struct { AccountID string Title string @@ -36,6 +42,7 @@ type PayBillInput struct { OccurredAt time.Time Description string CategoryID string + ActorID string } func (s *TransactionService) PayBill(input PayBillInput) (*model.Transaction, error) { @@ -86,6 +93,18 @@ func (s *TransactionService) PayBill(input PayBillInput) (*model.Transaction, er return nil, fmt.Errorf("failed to create bill transaction: %w", err) } + s.auditSvc.Record(TransactionRecordOptions{ + TransactionID: txn.ID, + ActorID: input.ActorID, + Action: model.TransactionAuditActionCreated, + Metadata: map[string]any{ + "account_id": txn.AccountID, + "transaction_type": string(model.TransactionTypeWithdrawal), + "title": txn.Title, + "amount": txn.Value.StringFixedBank(2), + }, + }) + return txn, nil } @@ -95,6 +114,7 @@ type DepositInput struct { Amount decimal.Decimal OccurredAt time.Time Description string + ActorID string } func (s *TransactionService) Deposit(input DepositInput) (*model.Transaction, error) { @@ -141,6 +161,18 @@ func (s *TransactionService) Deposit(input DepositInput) (*model.Transaction, er return nil, fmt.Errorf("failed to create deposit transaction: %w", err) } + s.auditSvc.Record(TransactionRecordOptions{ + TransactionID: txn.ID, + ActorID: input.ActorID, + Action: model.TransactionAuditActionCreated, + Metadata: map[string]any{ + "account_id": txn.AccountID, + "transaction_type": string(model.TransactionTypeDeposit), + "title": txn.Title, + "amount": txn.Value.StringFixedBank(2), + }, + }) + return txn, nil } @@ -151,6 +183,7 @@ type UpdateBillInput struct { OccurredAt time.Time Description string CategoryID string + ActorID string } func (s *TransactionService) UpdateBill(input UpdateBillInput) (*model.Transaction, error) { @@ -192,6 +225,15 @@ func (s *TransactionService) UpdateBill(input UpdateBillInput) (*model.Transacti categoryID = &c } + oldCategoryID, _ := s.transactionRepo.GetCategoryID(input.TransactionID) + changes := diffTransactionFields(existing, title, input.Amount, input.OccurredAt, description) + if !ptrEq(oldCategoryID, categoryID) { + changes["category_id"] = map[string]any{ + "old": ptrOrEmpty(oldCategoryID), + "new": ptrOrEmpty(categoryID), + } + } + existing.Value = input.Amount existing.Title = title existing.Description = description @@ -201,6 +243,14 @@ func (s *TransactionService) UpdateBill(input UpdateBillInput) (*model.Transacti if err := s.transactionRepo.UpdateBillAtomic(existing, newBalance, categoryID); err != nil { return nil, fmt.Errorf("failed to update bill transaction: %w", err) } + if len(changes) > 0 { + s.auditSvc.Record(TransactionRecordOptions{ + TransactionID: input.TransactionID, + ActorID: input.ActorID, + Action: model.TransactionAuditActionEdited, + Metadata: map[string]any{"changes": changes}, + }) + } return existing, nil } @@ -210,6 +260,7 @@ type UpdateDepositInput struct { Amount decimal.Decimal OccurredAt time.Time Description string + ActorID string } func (s *TransactionService) UpdateDeposit(input UpdateDepositInput) (*model.Transaction, error) { @@ -247,6 +298,8 @@ func (s *TransactionService) UpdateDeposit(input UpdateDepositInput) (*model.Tra description = &d } + changes := diffTransactionFields(existing, title, input.Amount, input.OccurredAt, description) + existing.Value = input.Amount existing.Title = title existing.Description = description @@ -256,9 +309,66 @@ func (s *TransactionService) UpdateDeposit(input UpdateDepositInput) (*model.Tra if err := s.transactionRepo.UpdateDepositAtomic(existing, newBalance); err != nil { return nil, fmt.Errorf("failed to update deposit transaction: %w", err) } + if len(changes) > 0 { + s.auditSvc.Record(TransactionRecordOptions{ + TransactionID: input.TransactionID, + ActorID: input.ActorID, + Action: model.TransactionAuditActionEdited, + Metadata: map[string]any{"changes": changes}, + }) + } return existing, nil } +// diffTransactionFields returns a map of field name to {old, new} for fields whose +// new value differs from the existing transaction. +func diffTransactionFields(existing *model.Transaction, newTitle string, newAmount decimal.Decimal, newOccurredAt time.Time, newDescription *string) map[string]any { + changes := map[string]any{} + if existing.Title != newTitle { + changes["title"] = map[string]any{"old": existing.Title, "new": newTitle} + } + if !existing.Value.Equal(newAmount) { + changes["amount"] = map[string]any{ + "old": existing.Value.StringFixedBank(2), + "new": newAmount.StringFixedBank(2), + } + } + if !existing.OccurredAt.Equal(newOccurredAt) { + changes["occurred_at"] = map[string]any{ + "old": existing.OccurredAt.Format("2006-01-02"), + "new": newOccurredAt.Format("2006-01-02"), + } + } + if !ptrStringEq(existing.Description, newDescription) { + changes["description"] = map[string]any{ + "old": ptrOrEmpty(existing.Description), + "new": ptrOrEmpty(newDescription), + } + } + return changes +} + +func ptrStringEq(a, b *string) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + +func ptrEq(a, b *string) bool { + return ptrStringEq(a, b) +} + +func ptrOrEmpty(p *string) string { + if p == nil { + return "" + } + return *p +} + func (s *TransactionService) GetTransaction(id string) (*model.Transaction, error) { txn, err := s.transactionRepo.GetByID(id) if err != nil { diff --git a/internal/service/transaction_audit_log.go b/internal/service/transaction_audit_log.go new file mode 100644 index 0000000..0c8c1bd --- /dev/null +++ b/internal/service/transaction_audit_log.go @@ -0,0 +1,78 @@ +package service + +import ( + "encoding/json" + "fmt" + "log/slog" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/repository" + "github.com/google/uuid" +) + +type TransactionAuditLogService struct { + repo repository.TransactionAuditLogRepository +} + +func NewTransactionAuditLogService(repo repository.TransactionAuditLogRepository) *TransactionAuditLogService { + return &TransactionAuditLogService{repo: repo} +} + +type TransactionRecordOptions struct { + TransactionID string + ActorID string + Action model.TransactionAuditAction + Metadata map[string]any +} + +// Record persists a transaction audit entry. Failures are logged but never bubble up — +// auditing must not break the user-facing action that triggered it. A nil receiver is +// a no-op so tests can omit the dependency. +func (s *TransactionAuditLogService) Record(opts TransactionRecordOptions) { + if s == nil { + return + } + entry := &model.TransactionAuditLog{ + ID: uuid.NewString(), + TransactionID: opts.TransactionID, + Action: opts.Action, + CreatedAt: time.Now(), + } + if opts.ActorID != "" { + actor := opts.ActorID + entry.ActorID = &actor + } + if len(opts.Metadata) > 0 { + raw, err := json.Marshal(opts.Metadata) + if err != nil { + slog.Error("failed to marshal transaction audit metadata", "error", err, "action", opts.Action) + } else { + entry.Metadata = raw + } + } + + if err := s.repo.Create(entry); err != nil { + slog.Error("failed to record transaction audit log", + "error", err, + "transaction_id", opts.TransactionID, + "action", opts.Action, + ) + } +} + +func (s *TransactionAuditLogService) List(transactionID string, limit, offset int) ([]*model.TransactionAuditLogWithActor, error) { + logs, err := s.repo.ListByTransaction(transactionID, limit, offset) + if err != nil { + return nil, fmt.Errorf("failed to list transaction audit logs: %w", err) + } + return logs, nil +} + +func (s *TransactionAuditLogService) Count(transactionID string) (int, error) { + count, err := s.repo.CountByTransaction(transactionID) + if err != nil { + return 0, fmt.Errorf("failed to count transaction audit logs: %w", err) + } + return count, nil +} diff --git a/internal/ui/pages/space_account_activity.templ b/internal/ui/pages/space_account_activity.templ new file mode 100644 index 0000000..bb1912f --- /dev/null +++ b/internal/ui/pages/space_account_activity.templ @@ -0,0 +1,238 @@ +package pages + +import "encoding/json" +import "fmt" +import "strings" + +import "git.juancwu.dev/juancwu/budgit/internal/model" +import "git.juancwu.dev/juancwu/budgit/internal/routeurl" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + +type SpaceAccountActivityPageProps struct { + SpaceID string + SpaceName string + AccountID string + AccountName string + Rows []model.AccountActivityRow + CurrentPage int + TotalPages int + TotalCount int + PerPage int +} + +templ SpaceAccountActivityPage(props SpaceAccountActivityPageProps) { + @layouts.AppWithBreadcrumb( + "Account Activity", + accountChildBreadcrumb(props.SpaceID, props.SpaceName, props.AccountID, props.AccountName, "Activity"), + spaceOverviewSidebarContent(), + spaceSpecificSidebarContent(props.SpaceID), + spaceAccountSidebarContent(props.SpaceID, props.AccountID), + ) { +
+
+ @button.Button(button.Props{ + Variant: button.VariantGhost, + Size: button.SizeSm, + Href: routeurl.URL("page.app.spaces.space.accounts.account.overview", "spaceID", props.SpaceID, "accountID", props.AccountID), + Class: "flex items-center gap-1 -ml-2", + }) { + @icon.ChevronLeft(icon.Props{Class: "size-4"}) + Back to account + } +
+
+

Activity

+

+ Account changes and transaction history for { props.AccountName }. +

+
+ @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Content(card.ContentProps{Class: "p-0"}) { + if len(props.Rows) == 0 { +

+ No activity yet. +

+ } else { +
    + for _, row := range props.Rows { + @accountActivityRow(props.SpaceID, props.AccountID, row) + } +
+ } + } + } + if props.TotalPages > 1 { + @accountActivityPagination(props) + } +
+ } +} + +templ accountActivityRow(spaceID, accountID string, row model.AccountActivityRow) { + if row.SpaceLog != nil { +
  • +
    + @activityIcon(row.SpaceLog.Action) +
    +
    +

    + @templ.Raw(activityMessage(row.SpaceLog)) +

    +

    + { row.SpaceLog.CreatedAt.Format("Jan 2, 2006 · 3:04 PM") } +

    +
    +
  • + } else if row.TxLog != nil { +
  • +
    + @accountActivityTxIcon(row.TxLog.Action) +
    +
    +

    + @templ.Raw(accountActivityTxMessage(spaceID, accountID, row.TxLog)) +

    + {{ changes := transactionActivityChanges(row.TxLog) }} + if len(changes) > 0 { +
      + for _, c := range changes { +
    • + @templ.Raw(c) +
    • + } +
    + } +

    + { row.TxLog.CreatedAt.Format("Jan 2, 2006 · 3:04 PM") } +

    +
    +
  • + } +} + +templ accountActivityTxIcon(action model.TransactionAuditAction) { + switch action { + case model.TransactionAuditActionCreated: + @icon.Plus(icon.Props{Class: "size-4 text-muted-foreground"}) + case model.TransactionAuditActionEdited: + @icon.Pencil(icon.Props{Class: "size-4 text-muted-foreground"}) + case model.TransactionAuditActionDeleted: + @icon.Trash2(icon.Props{Class: "size-4 text-destructive"}) + default: + @icon.History(icon.Props{Class: "size-4 text-muted-foreground"}) + } +} + +func transactionTypeLabel(t string) string { + switch t { + case string(model.TransactionTypeDeposit): + return "deposit" + case string(model.TransactionTypeWithdrawal): + return "bill" + default: + return "transaction" + } +} + +// accountActivityTxMessage formats a transaction-level audit entry for the account +// activity feed. For created/deleted, it includes the transaction type and title. +// For created/edited entries (where the transaction still exists), the title links +// to the transaction detail page. +func accountActivityTxMessage(spaceID, accountID string, log *model.TransactionAuditLogWithActor) string { + actor := bold(txActorLabel(log)) + switch log.Action { + case model.TransactionAuditActionCreated: + var meta struct { + TransactionType string `json:"transaction_type"` + Title string `json:"title"` + Amount string `json:"amount"` + } + _ = json.Unmarshal(log.Metadata, &meta) + title := meta.Title + if title == "" { + title = "a transaction" + } + titleHTML := transactionTitleLink(spaceID, accountID, log.TransactionID, title) + amountSuffix := "" + if meta.Amount != "" { + amountSuffix = fmt.Sprintf(" for $%s", templEscape(meta.Amount)) + } + return fmt.Sprintf("%s added a %s %s%s.", + actor, templEscape(transactionTypeLabel(meta.TransactionType)), titleHTML, amountSuffix) + case model.TransactionAuditActionEdited: + var meta struct { + Changes map[string]any `json:"changes"` + } + _ = json.Unmarshal(log.Metadata, &meta) + titleHTML := transactionTitleLink(spaceID, accountID, log.TransactionID, "a transaction") + return fmt.Sprintf("%s edited %s.", actor, titleHTML) + case model.TransactionAuditActionDeleted: + var meta struct { + TransactionType string `json:"transaction_type"` + Title string `json:"title"` + } + _ = json.Unmarshal(log.Metadata, &meta) + title := meta.Title + if title == "" { + title = "a transaction" + } + return fmt.Sprintf("%s deleted the %s %s.", + actor, templEscape(transactionTypeLabel(meta.TransactionType)), bold(title)) + default: + return fmt.Sprintf("%s performed %s.", actor, bold(string(log.Action))) + } +} + +func transactionTitleLink(spaceID, accountID, transactionID, title string) string { + href := routeurl.URL("page.app.spaces.space.accounts.account.transactions.transaction", + "spaceID", spaceID, "accountID", accountID, "transactionID", transactionID) + var b strings.Builder + b.WriteString(``) + b.WriteString(templEscape(title)) + b.WriteString(``) + return b.String() +} + +func accountActivityPageURL(spaceID, accountID string, page int) string { + return fmt.Sprintf("%s?page=%d", + routeurl.URL("page.app.spaces.space.accounts.account.activity", + "spaceID", spaceID, "accountID", accountID), page) +} + +templ accountActivityPagination(props SpaceAccountActivityPageProps) { + {{ p := pagination.CreatePagination(props.CurrentPage, props.TotalPages, 5) }} + @pagination.Pagination() { + @pagination.Content() { + @pagination.Item() { + @pagination.Previous(pagination.PreviousProps{ + Href: accountActivityPageURL(props.SpaceID, props.AccountID, p.CurrentPage-1), + Disabled: !p.HasPrevious, + Label: "Previous", + }) + } + for _, page := range p.Pages { + @pagination.Item() { + @pagination.Link(pagination.LinkProps{ + Href: accountActivityPageURL(props.SpaceID, props.AccountID, page), + IsActive: page == p.CurrentPage, + }) { + { fmt.Sprintf("%d", page) } + } + } + } + @pagination.Item() { + @pagination.Next(pagination.NextProps{ + Href: accountActivityPageURL(props.SpaceID, props.AccountID, p.CurrentPage+1), + Disabled: !p.HasNext, + Label: "Next", + }) + } + } + } +} diff --git a/internal/ui/pages/space_activity.templ b/internal/ui/pages/space_activity.templ index 73c4ac8..362a882 100644 --- a/internal/ui/pages/space_activity.templ +++ b/internal/ui/pages/space_activity.templ @@ -87,6 +87,12 @@ templ activityIcon(action model.SpaceAuditAction) { @icon.UserMinus(icon.Props{Class: "size-4 text-muted-foreground"}) case model.SpaceAuditActionInviteCancelled: @icon.X(icon.Props{Class: "size-4 text-muted-foreground"}) + case model.SpaceAuditActionAccountCreated: + @icon.Plus(icon.Props{Class: "size-4 text-muted-foreground"}) + case model.SpaceAuditActionAccountRenamed: + @icon.Pencil(icon.Props{Class: "size-4 text-muted-foreground"}) + case model.SpaceAuditActionAccountDeleted: + @icon.Trash2(icon.Props{Class: "size-4 text-destructive"}) default: @icon.History(icon.Props{Class: "size-4 text-muted-foreground"}) } @@ -148,6 +154,34 @@ func activityMessage(log *model.SpaceAuditLogWithActor) string { return fmt.Sprintf("%s removed %s from the space.", actor, target) case model.SpaceAuditActionInviteCancelled: return fmt.Sprintf("%s cancelled the invitation for %s.", actor, target) + case model.SpaceAuditActionAccountCreated: + var meta struct { + AccountName string `json:"account_name"` + } + _ = json.Unmarshal(log.Metadata, &meta) + name := meta.AccountName + if name == "" { + name = "an account" + } + return fmt.Sprintf("%s created the account %s.", actor, bold(name)) + case model.SpaceAuditActionAccountRenamed: + var meta struct { + OldName string `json:"old_name"` + NewName string `json:"new_name"` + } + _ = json.Unmarshal(log.Metadata, &meta) + return fmt.Sprintf("%s renamed account %s to %s.", + actor, bold(meta.OldName), bold(meta.NewName)) + case model.SpaceAuditActionAccountDeleted: + var meta struct { + AccountName string `json:"account_name"` + } + _ = json.Unmarshal(log.Metadata, &meta) + name := meta.AccountName + if name == "" { + name = "an account" + } + return fmt.Sprintf("%s deleted the account %s.", actor, bold(name)) default: return fmt.Sprintf("%s performed %s.", actor, bold(string(log.Action))) } diff --git a/internal/ui/pages/space_overview.templ b/internal/ui/pages/space_overview.templ index d6ca13a..0bc06d1 100644 --- a/internal/ui/pages/space_overview.templ +++ b/internal/ui/pages/space_overview.templ @@ -144,6 +144,16 @@ templ spaceAccountSidebarContent(spaceID, accountID string) { Deposit Funds } } + @sidebar.MenuItem() { + @sidebar.MenuButton(sidebar.MenuButtonProps{ + Href: routeurl.URL("page.app.spaces.space.accounts.account.activity", "spaceID", spaceID, "accountID", accountID), + IsActive: ctxkeys.URLPath(ctx) == routeurl.URL("page.app.spaces.space.accounts.account.activity", "spaceID", spaceID, "accountID", accountID), + Tooltip: "Account Activity", + }) { + @icon.History() + Activity + } + } @sidebar.MenuItem() { @sidebar.MenuButton(sidebar.MenuButtonProps{ Href: routeurl.URL("page.app.spaces.space.accounts.account.settings", "spaceID", spaceID, "accountID", accountID), diff --git a/internal/ui/pages/space_transaction.templ b/internal/ui/pages/space_transaction.templ index 71516e1..0c5a716 100644 --- a/internal/ui/pages/space_transaction.templ +++ b/internal/ui/pages/space_transaction.templ @@ -9,12 +9,14 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" import "git.juancwu.dev/juancwu/budgit/internal/ui/utils" type SpaceTransactionPageProps struct { - SpaceID string - SpaceName string - AccountID string - AccountName string - Transaction *model.Transaction - CategoryName string + SpaceID string + SpaceName string + AccountID string + AccountName string + Transaction *model.Transaction + CategoryName string + RecentAuditLogs []*model.TransactionAuditLogWithActor + AuditLogCount int } templ SpaceTransactionPage(props SpaceTransactionPageProps) { @@ -100,6 +102,35 @@ templ SpaceTransactionPage(props SpaceTransactionPageProps) { } } } + @card.Card() { + @card.Header() { +
    +

    Recent activity

    + if props.AuditLogCount > 0 { + @button.Button(button.Props{ + Variant: button.VariantLink, + Size: button.SizeSm, + Href: routeurl.URL("page.app.spaces.space.accounts.account.transactions.transaction.activity", "spaceID", props.SpaceID, "accountID", props.AccountID, "transactionID", props.Transaction.ID), + }) { + View all activity + } + } +
    + } + @card.Content(card.ContentProps{Class: "p-0"}) { + if len(props.RecentAuditLogs) == 0 { +

    + No edits yet. +

    + } else { +
      + for _, log := range props.RecentAuditLogs { + @transactionActivityRow(log) + } +
    + } + } + } } } diff --git a/internal/ui/pages/space_transaction_activity.templ b/internal/ui/pages/space_transaction_activity.templ new file mode 100644 index 0000000..06026c8 --- /dev/null +++ b/internal/ui/pages/space_transaction_activity.templ @@ -0,0 +1,235 @@ +package pages + +import "encoding/json" +import "fmt" + +import "git.juancwu.dev/juancwu/budgit/internal/model" +import "git.juancwu.dev/juancwu/budgit/internal/routeurl" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + +type SpaceTransactionActivityPageProps struct { + SpaceID string + SpaceName string + AccountID string + AccountName string + TransactionID string + TransactionName string + Logs []*model.TransactionAuditLogWithActor + CurrentPage int + TotalPages int + TotalCount int + PerPage int +} + +templ SpaceTransactionActivityPage(props SpaceTransactionActivityPageProps) { + @layouts.AppWithBreadcrumb( + "Transaction Activity", + accountChildBreadcrumb(props.SpaceID, props.SpaceName, props.AccountID, props.AccountName, props.TransactionName+" · Activity"), + spaceOverviewSidebarContent(), + spaceSpecificSidebarContent(props.SpaceID), + spaceAccountSidebarContent(props.SpaceID, props.AccountID), + ) { +
    +
    + @button.Button(button.Props{ + Variant: button.VariantGhost, + Size: button.SizeSm, + Href: routeurl.URL("page.app.spaces.space.accounts.account.transactions.transaction", "spaceID", props.SpaceID, "accountID", props.AccountID, "transactionID", props.TransactionID), + Class: "flex items-center gap-1 -ml-2", + }) { + @icon.ChevronLeft(icon.Props{Class: "size-4"}) + Back to transaction + } +
    +
    +

    Activity

    +

    + An audit log of edits to { props.TransactionName }. +

    +
    + @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Content(card.ContentProps{Class: "p-0"}) { + if len(props.Logs) == 0 { +

    + No activity yet. +

    + } else { +
      + for _, log := range props.Logs { + @transactionActivityRow(log) + } +
    + } + } + } + if props.TotalPages > 1 { + @transactionActivityPagination(props) + } +
    + } +} + +templ transactionActivityRow(log *model.TransactionAuditLogWithActor) { +
  • +
    + @transactionActivityIcon(log.Action) +
    +
    +

    + @templ.Raw(transactionActivityMessage(log)) +

    + {{ changes := transactionActivityChanges(log) }} + if len(changes) > 0 { +
      + for _, c := range changes { +
    • + @templ.Raw(c) +
    • + } +
    + } +

    + { log.CreatedAt.Format("Jan 2, 2006 · 3:04 PM") } +

    +
    +
  • +} + +templ transactionActivityIcon(action model.TransactionAuditAction) { + switch action { + case model.TransactionAuditActionEdited: + @icon.Pencil(icon.Props{Class: "size-4 text-muted-foreground"}) + default: + @icon.History(icon.Props{Class: "size-4 text-muted-foreground"}) + } +} + +func txActorLabel(log *model.TransactionAuditLogWithActor) string { + if log.ActorName != nil && *log.ActorName != "" { + return *log.ActorName + } + if log.ActorEmail != nil && *log.ActorEmail != "" { + return *log.ActorEmail + } + return "Someone" +} + +func transactionActivityMessage(log *model.TransactionAuditLogWithActor) string { + actor := bold(txActorLabel(log)) + switch log.Action { + case model.TransactionAuditActionEdited: + return fmt.Sprintf("%s edited the transaction.", actor) + default: + return fmt.Sprintf("%s performed %s.", actor, bold(string(log.Action))) + } +} + +// transactionActivityChanges parses the metadata for a transaction edit and returns +// a list of pre-escaped HTML fragments describing each changed field. +func transactionActivityChanges(log *model.TransactionAuditLogWithActor) []string { + if log.Action != model.TransactionAuditActionEdited || len(log.Metadata) == 0 { + return nil + } + var meta struct { + Changes map[string]struct { + Old any `json:"old"` + New any `json:"new"` + } `json:"changes"` + } + if err := json.Unmarshal(log.Metadata, &meta); err != nil { + return nil + } + if len(meta.Changes) == 0 { + return nil + } + order := []string{"title", "amount", "occurred_at", "description", "category_id"} + labels := map[string]string{ + "title": "Title", + "amount": "Amount", + "occurred_at": "Date", + "description": "Description", + "category_id": "Category", + } + var out []string + emit := func(field string) { + change, ok := meta.Changes[field] + if !ok { + return + } + if field == "category_id" { + out = append(out, fmt.Sprintf("%s changed.", templEscape(labels[field]))) + return + } + oldStr := fmt.Sprintf("%v", change.Old) + newStr := fmt.Sprintf("%v", change.New) + if oldStr == "" { + oldStr = "(empty)" + } + if newStr == "" { + newStr = "(empty)" + } + out = append(out, fmt.Sprintf("%s: %s → %s", + templEscape(labels[field]), bold(oldStr), bold(newStr))) + } + seen := map[string]bool{} + for _, f := range order { + emit(f) + seen[f] = true + } + for f := range meta.Changes { + if !seen[f] { + label := f + if l, ok := labels[f]; ok { + label = l + } + change := meta.Changes[f] + out = append(out, fmt.Sprintf("%s: %s → %s", + templEscape(label), + bold(fmt.Sprintf("%v", change.Old)), + bold(fmt.Sprintf("%v", change.New)))) + } + } + return out +} + +func transactionActivityPageURL(spaceID, accountID, transactionID string, page int) string { + return fmt.Sprintf("%s?page=%d", + routeurl.URL("page.app.spaces.space.accounts.account.transactions.transaction.activity", + "spaceID", spaceID, "accountID", accountID, "transactionID", transactionID), page) +} + +templ transactionActivityPagination(props SpaceTransactionActivityPageProps) { + {{ p := pagination.CreatePagination(props.CurrentPage, props.TotalPages, 5) }} + @pagination.Pagination() { + @pagination.Content() { + @pagination.Item() { + @pagination.Previous(pagination.PreviousProps{ + Href: transactionActivityPageURL(props.SpaceID, props.AccountID, props.TransactionID, p.CurrentPage-1), + Disabled: !p.HasPrevious, + Label: "Previous", + }) + } + for _, page := range p.Pages { + @pagination.Item() { + @pagination.Link(pagination.LinkProps{ + Href: transactionActivityPageURL(props.SpaceID, props.AccountID, props.TransactionID, page), + IsActive: page == p.CurrentPage, + }) { + { fmt.Sprintf("%d", page) } + } + } + } + @pagination.Item() { + @pagination.Next(pagination.NextProps{ + Href: transactionActivityPageURL(props.SpaceID, props.AccountID, props.TransactionID, p.CurrentPage+1), + Disabled: !p.HasNext, + Label: "Next", + }) + } + } + } +}