feat: transaction activity audit and account activity audit
This commit is contained in:
parent
ca7b2ff74f
commit
c96595d41e
19 changed files with 1259 additions and 20 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
18
internal/model/account_activity.go
Normal file
18
internal/model/account_activity.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
26
internal/model/transaction_audit_log.go
Normal file
26
internal/model/transaction_audit_log.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
90
internal/repository/transaction_audit_log.go
Normal file
90
internal/repository/transaction_audit_log.go
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
77
internal/service/account_activity.go
Normal file
77
internal/service/account_activity.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
78
internal/service/transaction_audit_log.go
Normal file
78
internal/service/transaction_audit_log.go
Normal 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
|
||||
}
|
||||
238
internal/ui/pages/space_account_activity.templ
Normal file
238
internal/ui/pages/space_account_activity.templ
Normal 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",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
235
internal/ui/pages/space_transaction_activity.templ
Normal file
235
internal/ui/pages/space_transaction_activity.templ
Normal 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",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue