Files
spend-sparrow/internal/service/treasure_chest.go
Tim Wundenberg 6291700a3b
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 8m11s
feat(observabillity): #153 instrument sqlx
2025-06-07 21:55:59 +02:00

322 lines
8.2 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"spend-sparrow/internal/db"
"spend-sparrow/internal/types"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type TreasureChest interface {
Add(ctx context.Context, user *types.User, parentId, name string) (*types.TreasureChest, error)
Update(ctx context.Context, user *types.User, id, parentId, name string) (*types.TreasureChest, error)
Get(ctx context.Context, user *types.User, id string) (*types.TreasureChest, error)
GetAll(ctx context.Context, user *types.User) ([]*types.TreasureChest, error)
Delete(ctx context.Context, user *types.User, id string) error
}
type TreasureChestImpl struct {
db *sqlx.DB
clock Clock
random Random
}
func NewTreasureChest(db *sqlx.DB, random Random, clock Clock) TreasureChest {
return TreasureChestImpl{
db: db,
clock: clock,
random: random,
}
}
func (s TreasureChestImpl) Add(ctx context.Context, user *types.User, parentId, name string) (*types.TreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
newId, err := s.random.UUID()
if err != nil {
return nil, types.ErrInternal
}
err = validateString(name, "name")
if err != nil {
return nil, err
}
var parentUuid *uuid.UUID
if parentId != "" {
parent, err := s.Get(ctx, user, parentId)
if err != nil {
return nil, err
}
if parent.ParentId != nil {
return nil, fmt.Errorf("only a depth of 1 allowed: %w", ErrBadRequest)
}
parentUuid = &parent.Id
}
treasureChest := &types.TreasureChest{
Id: newId,
ParentId: parentUuid,
UserId: user.Id,
Name: name,
CurrentBalance: 0,
CreatedAt: s.clock.Now(),
CreatedBy: user.Id,
UpdatedAt: nil,
UpdatedBy: nil,
}
r, err := s.db.NamedExecContext(ctx, `
INSERT INTO treasure_chest (id, parent_id, user_id, name, current_balance, created_at, created_by)
VALUES (:id, :parent_id, :user_id, :name, :current_balance, :created_at, :created_by)`, treasureChest)
err = db.TransformAndLogDbError("treasureChest Insert", r, err)
if err != nil {
return nil, err
}
return treasureChest, nil
}
func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr, parentId, name string) (*types.TreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
err := validateString(name, "name")
if err != nil {
return nil, err
}
id, err := uuid.Parse(idStr)
if err != nil {
slog.Error("treasureChest update", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
treasureChest := &types.TreasureChest{}
err = tx.GetContext(ctx, treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, id)
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("treasureChest %v not found: %w", idStr, err)
}
return nil, types.ErrInternal
}
var parentUuid *uuid.UUID
if parentId != "" {
parent, err := s.Get(ctx, user, parentId)
if err != nil {
return nil, err
}
var childCount int
err = tx.GetContext(ctx, &childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
if err != nil {
return nil, err
}
if parent.ParentId != nil || childCount > 0 {
return nil, fmt.Errorf("only one level allowed: %w", ErrBadRequest)
}
parentUuid = &parent.Id
}
timestamp := s.clock.Now()
treasureChest.Name = name
treasureChest.ParentId = parentUuid
treasureChest.UpdatedAt = &timestamp
treasureChest.UpdatedBy = &user.Id
r, err := tx.NamedExecContext(ctx, `
UPDATE treasure_chest
SET
parent_id = :parent_id,
name = :name,
current_balance = :current_balance,
updated_at = :updated_at,
updated_by = :updated_by
WHERE id = :id
AND user_id = :user_id`, treasureChest)
err = db.TransformAndLogDbError("treasureChest Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
if err != nil {
return nil, err
}
return treasureChest, nil
}
func (s TreasureChestImpl) Get(ctx context.Context, user *types.User, id string) (*types.TreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.Error("treasureChest get", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
var treasureChest types.TreasureChest
err = s.db.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("treasureChest Get", nil, err)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("treasureChest %v not found: %w", id, err)
}
return nil, types.ErrInternal
}
return &treasureChest, nil
}
func (s TreasureChestImpl) GetAll(ctx context.Context, user *types.User) ([]*types.TreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
treasureChests := make([]*types.TreasureChest, 0)
err := s.db.SelectContext(ctx, &treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError("treasureChest GetAll", nil, err)
if err != nil {
return nil, err
}
return sortTree(treasureChests), nil
}
func (s TreasureChestImpl) Delete(ctx context.Context, user *types.User, idStr string) error {
if user == nil {
return ErrUnauthorized
}
id, err := uuid.Parse(idStr)
if err != nil {
slog.Error("treasureChest delete", "err", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
if err != nil {
return nil
}
defer func() {
_ = tx.Rollback()
}()
childCount := 0
err = tx.GetContext(ctx, &childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
if err != nil {
return err
}
if childCount > 0 {
return fmt.Errorf("treasure chest has children: %w", ErrBadRequest)
}
transactionsCount := 0
err = tx.GetContext(ctx, &transactionsCount,
`SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND treasure_chest_id = ?`,
user.Id, id)
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
if err != nil {
return err
}
if transactionsCount > 0 {
return fmt.Errorf("treasure chest has transactions: %w", ErrBadRequest)
}
recurringCount := 0
err = tx.GetContext(ctx, &recurringCount, `
SELECT COUNT(*) FROM transaction_recurring WHERE user_id = ? AND treasure_chest_id = ?`,
user.Id, id)
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
if err != nil {
return err
}
if recurringCount > 0 {
return fmt.Errorf("cannot delete treasure chest with existing recurring transactions: %w", ErrBadRequest)
}
r, err := tx.ExecContext(ctx, `DELETE FROM treasure_chest WHERE id = ? AND user_id = ?`, id, user.Id)
err = db.TransformAndLogDbError("treasureChest Delete", r, err)
if err != nil {
return err
}
err = tx.Commit()
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
if err != nil {
return err
}
return nil
}
func sortTree(nodes []*types.TreasureChest) []*types.TreasureChest {
var (
roots []*types.TreasureChest
)
children := make(map[uuid.UUID][]*types.TreasureChest)
result := make([]*types.TreasureChest, 0)
for _, node := range nodes {
if node.ParentId == nil {
roots = append(roots, node)
} else {
children[*node.ParentId] = append(children[*node.ParentId], node)
}
}
slices.SortFunc(roots, func(a, b *types.TreasureChest) int {
return compareStrings(a.Name, b.Name)
})
for _, root := range roots {
result = append(result, root)
childList := children[root.Id]
slices.SortFunc(childList, func(a, b *types.TreasureChest) int {
return compareStrings(a.Name, b.Name)
})
result = append(result, childList...)
}
return result
}
func compareStrings(a, b string) int {
if a == b {
return 0
}
if a < b {
return -1
}
return 1
}