diff --git a/db/transaction.go b/db/transaction.go new file mode 100644 index 0000000..e3c4472 --- /dev/null +++ b/db/transaction.go @@ -0,0 +1,136 @@ +package db + +import ( + "database/sql" + "spend-sparrow/log" + "spend-sparrow/types" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +// While it may be duplicated to check for userId in the database access, it serves as a security layer +type Transaction interface { + Insert(userId uuid.UUID, transaction *types.Transaction) error + Update(userId uuid.UUID, transaction *types.Transaction) error + GetAll(userId uuid.UUID) ([]*types.Transaction, error) + Get(userId uuid.UUID, id uuid.UUID) (*types.Transaction, error) + Delete(userId uuid.UUID, id uuid.UUID) error +} + +type TransactionSqlite struct { + db *sqlx.DB +} + +func NewTransactionSqlite(db *sqlx.DB) *TransactionSqlite { + return &TransactionSqlite{db: db} +} + +func (db TransactionSqlite) Insert(userId uuid.UUID, transaction *types.Transaction) error { + + _, err := db.db.Exec(` + INSERT INTO transaction (id, user_id, account_id, treasure_chest_id, internal, value, timestamp, note, created_at, created_by) + VALUES (?,?,?,?,?,?,?,?,?,?)`, transaction.Id, userId, transaction.AccountId, transaction.TreasureChestId, transaction.Internal, transaction.Value, transaction.Timestamp, transaction.Note, transaction.CreatedAt, transaction.CreatedBy) + if err != nil { + log.Error("transaction Insert: %v", err) + return types.ErrInternal + } + + return nil +} + +func (db TransactionSqlite) Update(userId uuid.UUID, transaction *types.Transaction) error { + + r, err := db.db.Exec(` + UPDATE transaction + SET + account_id = ?, + treasure_chest_id = ?, + internal = ?, + value = ?, + timestamp = ?, + note = ?, + updated_at = ?, + updated_by = ? + WHERE id = ? + AND user_id = ?`, transaction.AccountId, transaction.TreasureChestId, transaction.Internal, transaction.Value, transaction.Timestamp, transaction.UpdatedBy, transaction.Id, userId) + if err != nil { + log.Error("transaction Update: %v", err) + return types.ErrInternal + } + rows, err := r.RowsAffected() + if err != nil { + log.Error("transaction Update: %v", err) + return types.ErrInternal + } + + if rows == 0 { + log.Info("transaction Update: not found") + return ErrNotFound + } + + return nil +} + +func (db TransactionSqlite) GetAll(userId uuid.UUID) ([]*types.Transaction, error) { + + transactions := make([]*types.Transaction, 0) + err := db.db.Select(&transactions, ` + SELECT + id, user_id, + account_id, treasure_chest_id, internal, value, timestamp, note, + created_at, created_by, updated_at, updated_by + FROM transaction + WHERE user_id = ? + ORDER BY name`, userId) + if err != nil { + log.Error("transaction GetAll: %v", err) + return nil, types.ErrInternal + } + + return transactions, nil +} + +func (db TransactionSqlite) Get(userId uuid.UUID, id uuid.UUID) (*types.Transaction, error) { + + transaction := &types.Transaction{} + err := db.db.Get(transaction, ` + SELECT + id, user_id, + account_id, treasure_chest_id, internal, value, timestamp, note, + created_at, created_by, updated_at, updated_by + FROM transaction + WHERE user_id = ? + AND id = ?`, userId, id) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + log.Error("transaction Get: %v", err) + return nil, types.ErrInternal + } + + return transaction, nil +} + +func (db TransactionSqlite) Delete(userId uuid.UUID, id uuid.UUID) error { + + res, err := db.db.Exec("DELETE FROM transaction WHERE id = ? and user_id = ?", id, userId) + if err != nil { + log.Error("transaction Delete: %v", err) + return types.ErrInternal + } + + rows, err := res.RowsAffected() + if err != nil { + log.Error("transaction Delete: %v", err) + return types.ErrInternal + } + + if rows == 0 { + log.Info("transaction Delete: not found") + return ErrNotFound + } + + return nil +} diff --git a/handler/transaction.go b/handler/transaction.go new file mode 100644 index 0000000..c1c8ec6 --- /dev/null +++ b/handler/transaction.go @@ -0,0 +1,138 @@ +package handler + +import ( + "net/http" + "spend-sparrow/handler/middleware" + "spend-sparrow/service" + t "spend-sparrow/template/transaction" + "spend-sparrow/types" + "spend-sparrow/utils" + + "github.com/a-h/templ" +) + +type Transaction interface { + Handle(router *http.ServeMux) +} + +type TransactionImpl struct { + s service.Transaction + a service.Auth + r *Render +} + +func NewTransaction(s service.Transaction, a service.Auth, r *Render) Transaction { + return TransactionImpl{ + s: s, + a: a, + r: r, + } +} + +func (h TransactionImpl) Handle(r *http.ServeMux) { + r.Handle("GET /transaction", h.handleTransactionPage()) + r.Handle("GET /transaction/{id}", h.handleTransactionItemComp()) + r.Handle("POST /transaction/{id}", h.handleUpdateTransaction()) + r.Handle("DELETE /transaction/{id}", h.handleDeleteTransaction()) +} + +func (h TransactionImpl) handleTransactionPage() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + transactions, err := h.s.GetAll(user) + if err != nil { + handleError(w, r, err) + return + } + + comp := t.Transaction(transactions) + h.r.RenderLayout(r, w, comp, user) + } +} + +func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + id := r.PathValue("id") + if id == "new" { + comp := t.EditTransaction(nil) + h.r.Render(r, w, comp) + return + } + + transaction, err := h.s.Get(user, id) + if err != nil { + handleError(w, r, err) + return + } + + var comp templ.Component + if r.URL.Query().Get("edit") == "true" { + comp = t.EditTransaction(transaction) + } else { + comp = t.TransactionItem(transaction) + } + h.r.Render(r, w, comp) + } +} + +func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + var ( + transaction *types.Transaction + err error + ) + id := r.PathValue("id") + name := r.FormValue("name") + if id == "new" { + transaction, err = h.s.Add(user, name) + if err != nil { + handleError(w, r, err) + return + } + } else { + transaction, err = h.s.Update(user, id, name) + if err != nil { + handleError(w, r, err) + return + } + } + + comp := t.TransactionItem(transaction) + h.r.Render(r, w, comp) + } +} + +func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + id := r.PathValue("id") + + err := h.s.Delete(user, id) + if err != nil { + handleError(w, r, err) + return + } + } +} diff --git a/service/transaction.go b/service/transaction.go new file mode 100644 index 0000000..acab508 --- /dev/null +++ b/service/transaction.go @@ -0,0 +1,270 @@ +package service + +import ( + "fmt" + "strconv" + "time" + + "spend-sparrow/db" + "spend-sparrow/log" + "spend-sparrow/types" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + transactionMetric = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "spendsparrow_transaction_total", + Help: "The total of transaction operations", + }, + []string{"operation"}, + ) +) + +type Transaction interface { + Add(user *types.User, accountId, treasureChestId, internal, value, timestamp, note string) (*types.Transaction, error) + Update(user *types.User, id, accountId, treasureChestId, internal, value, timestamp, note string) (*types.Transaction, error) + Get(user *types.User, id string) (*types.Transaction, error) + GetAll(user *types.User) ([]*types.Transaction, error) + Delete(user *types.User, id string) error + CanDeleteTreasureChest(user *types.User, treasureChestId uuid.UUID) (bool, error) + CanDeleteAccount(user *types.User, accountId uuid.UUID) (bool, error) +} + +type TransactionImpl struct { + db db.Transaction + account Account + treasureChest TreasureChest + clock Clock + random Random + settings *types.Settings +} + +func NewTransaction(db db.Transaction, account Account, treasureChest TreasureChest, random Random, clock Clock, settings *types.Settings) Transaction { + return TransactionImpl{ + db: db, + account: account, + treasureChest: treasureChest, + clock: clock, + random: random, + settings: settings, + } +} + +func (s TransactionImpl) Add(user *types.User, accountId, treasureChestId, internal, value, timestamp, note string) (*types.Transaction, error) { + transactionMetric.WithLabelValues("add").Inc() + + if user == nil { + return nil, ErrUnauthorized + } + + transaction, err := s.validateTransaction(nil, user.Id, accountId, treasureChestId, internal, value, timestamp, note) + if err != nil { + return nil, err + } + + err = s.db.Insert(user.Id, transaction) + if err != nil { + return nil, types.ErrInternal + } + + savedTransaction, err := s.db.Get(user.Id, transaction.Id) + if err != nil { + log.Error("transaction %v not found after insert: %v", transaction.Id, err) + return nil, types.ErrInternal + } + return savedTransaction, nil +} + +func (s TransactionImpl) Update(user *types.User, id, accountId, treasureChestId, internal, value, timestamp, note string) (*types.Transaction, error) { + transactionMetric.WithLabelValues("update").Inc() + if user == nil { + return nil, ErrUnauthorized + } + uuid, err := uuid.Parse(id) + if err != nil { + log.Error("transaction update: %v", err) + return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest) + } + + transaction, err := s.db.Get(user.Id, uuid) + if err != nil { + if err == db.ErrNotFound { + return nil, fmt.Errorf("transaction %v not found: %w", id, ErrBadRequest) + } + return nil, types.ErrInternal + } + + transaction, err = s.validateTransaction(transaction, user.Id, accountId, treasureChestId, internal, value, timestamp, note) + if err != nil { + return nil, err + } + + err = s.db.Update(user.Id, transaction) + if err != nil { + return nil, types.ErrInternal + } + + return transaction, nil +} + +func (s TransactionImpl) Get(user *types.User, id string) (*types.Transaction, error) { + transactionMetric.WithLabelValues("get").Inc() + + if user == nil { + return nil, ErrUnauthorized + } + uuid, err := uuid.Parse(id) + if err != nil { + log.Error("transaction get: %v", err) + return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest) + } + + transaction, err := s.db.Get(user.Id, uuid) + if err != nil { + if err == db.ErrNotFound { + return nil, fmt.Errorf("transaction %v not found: %w", id, ErrBadRequest) + } + return nil, types.ErrInternal + } + + return transaction, nil +} + +func (s TransactionImpl) GetAll(user *types.User) ([]*types.Transaction, error) { + transactionMetric.WithLabelValues("get_all").Inc() + if user == nil { + return nil, ErrUnauthorized + } + + transactions, err := s.db.GetAll(user.Id) + if err != nil { + return nil, types.ErrInternal + } + + return transactions, nil +} + +func (s TransactionImpl) Delete(user *types.User, id string) error { + transactionMetric.WithLabelValues("delete").Inc() + if user == nil { + return ErrUnauthorized + } + uuid, err := uuid.Parse(id) + if err != nil { + log.Error("transaction delete: %v", err) + return fmt.Errorf("could not parse Id: %w", ErrBadRequest) + } + + transaction, err := s.db.Get(user.Id, uuid) + if err != nil { + if err == db.ErrNotFound { + return fmt.Errorf("transaction %v not found: %w", id, ErrBadRequest) + } + return types.ErrInternal + } + + if transaction.UserId != user.Id { + return types.ErrUnauthorized + } + + err = s.db.Delete(user.Id, transaction.Id) + if err != nil { + return types.ErrInternal + } + + return nil +} + +func (s TransactionImpl) validateTransaction(transaction *types.Transaction, userId uuid.UUID, accountId, treasureChestId, internal, value, timestamp, note string) (*types.Transaction, error) { + + var ( + id uuid.UUID + accountUuid uuid.UUID + treasureChestUuid uuid.UUID + internalBool bool + createdAt time.Time + createdBy uuid.UUID + updatedAt *time.Time + updatedBy uuid.UUID + + err error + ) + + if transaction == nil { + id, err = s.random.UUID() + if err != nil { + return nil, types.ErrInternal + } + createdAt = s.clock.Now() + createdBy = userId + } else { + id = transaction.Id + createdAt = transaction.CreatedAt + createdBy = transaction.CreatedBy + time := s.clock.Now() + updatedAt = &time + updatedBy = userId + } + + if accountId != "" { + accountUuid, err = uuid.Parse(accountId) + if err != nil { + log.Error("transaction validate: %v", err) + return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest) + } + } + + if treasureChestId != "" { + treasureChestUuid, err = uuid.Parse(treasureChestId) + if err != nil { + log.Error("transaction validate: %v", err) + return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest) + } + } + + internalBool, err = strconv.ParseBool(internal) + if err != nil { + log.Error("transaction validate: %v", err) + return nil, fmt.Errorf("could not parse internal: %w", ErrBadRequest) + } + + valueInt, err := strconv.ParseInt(value, 10, 64) + if err != nil { + log.Error("transaction validate: %v", err) + return nil, fmt.Errorf("could not parse value: %w", ErrBadRequest) + } + + timestampTime, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + log.Error("transaction validate: %v", err) + return nil, fmt.Errorf("could not parse timestamp: %w", ErrBadRequest) + } + + err = validateString(note) + if err != nil { + return nil, err + } + + result := types.Transaction{ + Id: id, + UserId: userId, + + AccountId: accountUuid, + TreasureChestId: &treasureChestUuid, + Internal: internalBool, + Value: valueInt, + Timestamp: timestampTime, + Note: note, + + CreatedAt: createdAt, + CreatedBy: createdBy, + UpdatedAt: updatedAt, + UpdatedBy: &updatedBy, + } + + return &result, nil +} diff --git a/template/transaction/default.go b/template/transaction/default.go new file mode 100644 index 0000000..0619207 --- /dev/null +++ b/template/transaction/default.go @@ -0,0 +1 @@ +package transaction diff --git a/template/transaction/transaction.templ b/template/transaction/transaction.templ new file mode 100644 index 0000000..d2380e1 --- /dev/null +++ b/template/transaction/transaction.templ @@ -0,0 +1,114 @@ +package transaction + +import "fmt" +import "spend-sparrow/template/svg" +import "spend-sparrow/types" + +templ Transaction(transactions []*types.Transaction) { +
{ transaction.Name }
+{ displayBalance(transaction.CurrentBalance) }
+ + +