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), + ) { +
+ Account changes and transaction history for { props.AccountName }. +
++ No activity yet. +
+ } else { ++ @templ.Raw(activityMessage(row.SpaceLog)) +
++ { row.SpaceLog.CreatedAt.Format("Jan 2, 2006 · 3:04 PM") } +
++ @templ.Raw(accountActivityTxMessage(spaceID, accountID, row.TxLog)) +
+ {{ changes := transactionActivityChanges(row.TxLog) }} + if len(changes) > 0 { ++ { row.TxLog.CreatedAt.Format("Jan 2, 2006 · 3:04 PM") } +
++ No edits yet. +
+ } else { ++ An audit log of edits to { props.TransactionName }. +
++ No activity yet. +
+ } else { ++ @templ.Raw(transactionActivityMessage(log)) +
+ {{ changes := transactionActivityChanges(log) }} + if len(changes) > 0 { ++ { log.CreatedAt.Format("Jan 2, 2006 · 3:04 PM") } +
+