feat: transaction activity audit and account activity audit

This commit is contained in:
juancwu 2026-05-03 23:50:39 +00:00
commit c96595d41e
19 changed files with 1259 additions and 20 deletions

View file

@ -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
}

View file

@ -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

View file

@ -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)

View file

@ -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
}

View file

@ -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 {

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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")

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)

View file

@ -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 {

View file

@ -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
}

View file

@ -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),
) {
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-6">
<div class="flex items-center gap-2">
@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
}
</div>
<div>
<h1 class="text-3xl font-bold">Activity</h1>
<p class="text-muted-foreground mt-2">
Account changes and transaction history for { props.AccountName }.
</p>
</div>
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-0"}) {
if len(props.Rows) == 0 {
<p class="px-6 py-10 text-sm text-muted-foreground text-center">
No activity yet.
</p>
} else {
<ol class="divide-y">
for _, row := range props.Rows {
@accountActivityRow(props.SpaceID, props.AccountID, row)
}
</ol>
}
}
}
if props.TotalPages > 1 {
@accountActivityPagination(props)
}
</div>
}
}
templ accountActivityRow(spaceID, accountID string, row model.AccountActivityRow) {
if row.SpaceLog != nil {
<li class="flex gap-3 px-6 py-4">
<div class="w-9 h-9 shrink-0 rounded-full bg-muted flex items-center justify-center">
@activityIcon(row.SpaceLog.Action)
</div>
<div class="flex-1 min-w-0">
<p class="text-sm">
@templ.Raw(activityMessage(row.SpaceLog))
</p>
<p class="text-xs text-muted-foreground mt-1">
{ row.SpaceLog.CreatedAt.Format("Jan 2, 2006 · 3:04 PM") }
</p>
</div>
</li>
} else if row.TxLog != nil {
<li class="flex gap-3 px-6 py-4">
<div class="w-9 h-9 shrink-0 rounded-full bg-muted flex items-center justify-center">
@accountActivityTxIcon(row.TxLog.Action)
</div>
<div class="flex-1 min-w-0 space-y-1">
<p class="text-sm">
@templ.Raw(accountActivityTxMessage(spaceID, accountID, row.TxLog))
</p>
{{ changes := transactionActivityChanges(row.TxLog) }}
if len(changes) > 0 {
<ul class="text-xs text-muted-foreground space-y-0.5 mt-1">
for _, c := range changes {
<li>
@templ.Raw(c)
</li>
}
</ul>
}
<p class="text-xs text-muted-foreground">
{ row.TxLog.CreatedAt.Format("Jan 2, 2006 · 3:04 PM") }
</p>
</div>
</li>
}
}
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(`<a class="font-semibold underline-offset-2 hover:underline" href="`)
b.WriteString(templEscape(href))
b.WriteString(`">`)
b.WriteString(templEscape(title))
b.WriteString(`</a>`)
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",
})
}
}
}
}

View file

@ -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)))
}

View file

@ -144,6 +144,16 @@ templ spaceAccountSidebarContent(spaceID, accountID string) {
<span>Deposit Funds</span>
}
}
@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()
<span>Activity</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: routeurl.URL("page.app.spaces.space.accounts.account.settings", "spaceID", spaceID, "accountID", accountID),

View file

@ -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() {
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Recent activity</h2>
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
}
}
</div>
}
@card.Content(card.ContentProps{Class: "p-0"}) {
if len(props.RecentAuditLogs) == 0 {
<p class="px-6 py-8 text-sm text-muted-foreground text-center">
No edits yet.
</p>
} else {
<ol class="divide-y">
for _, log := range props.RecentAuditLogs {
@transactionActivityRow(log)
}
</ol>
}
}
}
</div>
}
}

View file

@ -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),
) {
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-6">
<div class="flex items-center gap-2">
@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
}
</div>
<div>
<h1 class="text-3xl font-bold">Activity</h1>
<p class="text-muted-foreground mt-2">
An audit log of edits to { props.TransactionName }.
</p>
</div>
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-0"}) {
if len(props.Logs) == 0 {
<p class="px-6 py-10 text-sm text-muted-foreground text-center">
No activity yet.
</p>
} else {
<ol class="divide-y">
for _, log := range props.Logs {
@transactionActivityRow(log)
}
</ol>
}
}
}
if props.TotalPages > 1 {
@transactionActivityPagination(props)
}
</div>
}
}
templ transactionActivityRow(log *model.TransactionAuditLogWithActor) {
<li class="flex gap-3 px-6 py-4">
<div class="w-9 h-9 shrink-0 rounded-full bg-muted flex items-center justify-center">
@transactionActivityIcon(log.Action)
</div>
<div class="flex-1 min-w-0 space-y-1">
<p class="text-sm">
@templ.Raw(transactionActivityMessage(log))
</p>
{{ changes := transactionActivityChanges(log) }}
if len(changes) > 0 {
<ul class="text-xs text-muted-foreground space-y-0.5 mt-1">
for _, c := range changes {
<li>
@templ.Raw(c)
</li>
}
</ul>
}
<p class="text-xs text-muted-foreground">
{ log.CreatedAt.Format("Jan 2, 2006 · 3:04 PM") }
</p>
</div>
</li>
}
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",
})
}
}
}
}