feat(treasurechest): #64 implement hirarchical treasure chests
Build Docker Image / Build-Docker-Image (push) Successful in 4m4s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m6s

This commit was merged in pull request #65.
This commit is contained in:
2025-05-12 23:37:47 +02:00
parent df022c9077
commit 96ca636fbb
21 changed files with 868 additions and 108 deletions
+28 -23
View File
@@ -27,10 +27,10 @@ var (
type Account interface {
Add(user *types.User, name string) (*types.Account, error)
Update(user *types.User, id uuid.UUID, name string) (*types.Account, error)
Get(user *types.User, id uuid.UUID) (*types.Account, error)
Update(user *types.User, id string, name string) (*types.Account, error)
Get(user *types.User, id string) (*types.Account, error)
GetAll(user *types.User) ([]*types.Account, error)
Delete(user *types.User, id uuid.UUID) error
Delete(user *types.User, id string) error
}
type AccountImpl struct {
@@ -40,11 +40,11 @@ type AccountImpl struct {
settings *types.Settings
}
func NewAccountImpl(db db.Account, random Random, clock Clock, settings *types.Settings) Account {
func NewAccount(db db.Account, random Random, clock Clock, settings *types.Settings) Account {
return AccountImpl{
db: db,
clock: clock,
random: NewRandomImpl(),
random: random,
settings: settings,
}
}
@@ -61,7 +61,7 @@ func (s AccountImpl) Add(user *types.User, name string) (*types.Account, error)
return nil, types.ErrInternal
}
err = s.validateAccount(name)
err = validateString(name)
if err != nil {
return nil, err
}
@@ -95,17 +95,22 @@ func (s AccountImpl) Add(user *types.User, name string) (*types.Account, error)
return savedAccount, nil
}
func (s AccountImpl) Update(user *types.User, id uuid.UUID, name string) (*types.Account, error) {
func (s AccountImpl) Update(user *types.User, id string, name string) (*types.Account, error) {
accountMetric.WithLabelValues("update").Inc()
if user == nil {
return nil, ErrUnauthorized
}
err := s.validateAccount(name)
err := validateString(name)
if err != nil {
return nil, err
}
uuid, err := uuid.Parse(id)
if err != nil {
log.Error("account update: %v", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
account, err := s.db.Get(user.Id, id)
account, err := s.db.Get(user.Id, uuid)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("account %v not found: %w", id, ErrBadRequest)
@@ -126,14 +131,19 @@ func (s AccountImpl) Update(user *types.User, id uuid.UUID, name string) (*types
return account, nil
}
func (s AccountImpl) Get(user *types.User, id uuid.UUID) (*types.Account, error) {
func (s AccountImpl) Get(user *types.User, id string) (*types.Account, error) {
accountMetric.WithLabelValues("get").Inc()
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
log.Error("account get: %v", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
account, err := s.db.Get(user.Id, id)
account, err := s.db.Get(user.Id, uuid)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("account %v not found: %w", id, ErrBadRequest)
@@ -158,13 +168,18 @@ func (s AccountImpl) GetAll(user *types.User) ([]*types.Account, error) {
return accounts, nil
}
func (s AccountImpl) Delete(user *types.User, id uuid.UUID) error {
func (s AccountImpl) Delete(user *types.User, id string) error {
accountMetric.WithLabelValues("delete").Inc()
if user == nil {
return ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
log.Error("account delete: %v", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
account, err := s.db.Get(user.Id, id)
account, err := s.db.Get(user.Id, uuid)
if err != nil {
if err == db.ErrNotFound {
return fmt.Errorf("account %v not found: %w", id, ErrBadRequest)
@@ -183,13 +198,3 @@ func (s AccountImpl) Delete(user *types.User, id uuid.UUID) error {
return nil
}
func (s AccountImpl) validateAccount(name string) error {
if name == "" {
return fmt.Errorf("field \"name\" needs to be set: %w", ErrBadRequest)
} else if !safeInputRegex.MatchString(name) {
return fmt.Errorf("use only letters, dashes and spaces for \"name\": %w", ErrBadRequest)
} else {
return nil
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ type AuthImpl struct {
serverSettings *types.Settings
}
func NewAuthImpl(db db.Auth, random Random, clock Clock, mail Mail, serverSettings *types.Settings) *AuthImpl {
func NewAuth(db db.Auth, random Random, clock Clock, mail Mail, serverSettings *types.Settings) *AuthImpl {
return &AuthImpl{
db: db,
random: random,
+5 -5
View File
@@ -24,7 +24,7 @@ func TestSignUp(t *testing.T) {
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
underTest := NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
_, err := underTest.SignUp("invalid email address", "SomeStrongPassword123!")
@@ -38,7 +38,7 @@ func TestSignUp(t *testing.T) {
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
underTest := NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
weakPasswords := []string{
"123!ab", // too short
@@ -73,7 +73,7 @@ func TestSignUp(t *testing.T) {
mockClock.EXPECT().Now().Return(createTime)
mockAuthDb.EXPECT().InsertUser(expected).Return(nil)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
underTest := NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
actual, err := underTest.SignUp(email, password)
assert.Nil(t, err)
@@ -101,7 +101,7 @@ func TestSignUp(t *testing.T) {
mockAuthDb.EXPECT().InsertUser(user).Return(db.ErrAlreadyExists)
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
underTest := NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
_, err := underTest.SignUp(user.Email, password)
assert.Equal(t, ErrAccountExists, err)
@@ -131,7 +131,7 @@ func TestSendVerificationMail(t *testing.T) {
return strings.Contains(message, token.Token)
})).Return()
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
underTest := NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
underTest.SendVerificationMail(userId, email)
})
+1 -1
View File
@@ -8,7 +8,7 @@ type Clock interface {
type ClockImpl struct{}
func NewClockImpl() Clock {
func NewClock() Clock {
return &ClockImpl{}
}
+13
View File
@@ -0,0 +1,13 @@
package service
import "fmt"
func validateString(value string) error {
if value == "" {
return fmt.Errorf("field \"name\" needs to be set: %w", ErrBadRequest)
} else if !safeInputRegex.MatchString(value) {
return fmt.Errorf("use only letters, dashes and spaces for \"name\": %w", ErrBadRequest)
} else {
return nil
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ type MailImpl struct {
server *types.Settings
}
func NewMailImpl(server *types.Settings) MailImpl {
func NewMail(server *types.Settings) MailImpl {
return MailImpl{server: server}
}
+1 -1
View File
@@ -19,7 +19,7 @@ type Random interface {
type RandomImpl struct {
}
func NewRandomImpl() *RandomImpl {
func NewRandom() *RandomImpl {
return &RandomImpl{}
}
+239
View File
@@ -0,0 +1,239 @@
package service
import (
"fmt"
"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 (
treasureChestMetric = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "spendsparrow_treasurechest_total",
Help: "The total of treasurechest operations",
},
[]string{"operation"},
)
)
type TreasureChest interface {
Add(user *types.User, parentId, name string) (*types.TreasureChest, error)
Update(user *types.User, id, parentId, name string) (*types.TreasureChest, error)
Get(user *types.User, id string) (*types.TreasureChest, error)
GetAll(user *types.User) ([]*types.TreasureChest, error)
Delete(user *types.User, id string) error
}
type TreasureChestImpl struct {
db db.TreasureChest
clock Clock
random Random
settings *types.Settings
}
func NewTreasureChest(db db.TreasureChest, random Random, clock Clock, settings *types.Settings) TreasureChest {
return TreasureChestImpl{
db: db,
clock: clock,
random: random,
settings: settings,
}
}
func (s TreasureChestImpl) Add(user *types.User, parentId, name string) (*types.TreasureChest, error) {
treasureChestMetric.WithLabelValues("add").Inc()
if user == nil {
return nil, ErrUnauthorized
}
newId, err := s.random.UUID()
if err != nil {
return nil, types.ErrInternal
}
err = validateString(name)
if err != nil {
return nil, err
}
parentUuid := uuid.Nil
if parentId != "" {
parent, err := s.Get(user, parentId)
if err != nil {
return nil, err
}
if parent.ParentId != uuid.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,
}
err = s.db.Insert(user.Id, treasureChest)
if err != nil {
return nil, types.ErrInternal
}
savedtreasureChest, err := s.db.Get(user.Id, newId)
if err != nil {
log.Error("treasureChest %v not found after insert: %v", newId, err)
return nil, types.ErrInternal
}
return savedtreasureChest, nil
}
func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string) (*types.TreasureChest, error) {
treasureChestMetric.WithLabelValues("update").Inc()
if user == nil {
return nil, ErrUnauthorized
}
err := validateString(name)
if err != nil {
return nil, err
}
id, err := uuid.Parse(idStr)
if err != nil {
log.Error("treasureChest update: %v", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
treasureChest, err := s.db.Get(user.Id, id)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("treasureChest %v not found: %w", idStr, ErrBadRequest)
}
return nil, types.ErrInternal
}
parentUuid := uuid.Nil
if parentId != "" {
parent, err := s.Get(user, parentId)
if err != nil {
return nil, err
}
if parent.ParentId != uuid.Nil {
return nil, fmt.Errorf("only a depth of 1 allowed: %w", ErrBadRequest)
}
children, err := s.db.GetAllByParentId(user.Id, treasureChest.Id)
if err != nil {
return nil, err
}
if len(children) > 0 {
return nil, fmt.Errorf("only a depth of 1 allowed: %w", ErrBadRequest)
}
parentUuid = parent.Id
}
timestamp := s.clock.Now()
treasureChest.Name = name
treasureChest.ParentId = parentUuid
treasureChest.UpdatedAt = &timestamp
treasureChest.UpdatedBy = &user.Id
err = s.db.Update(user.Id, treasureChest)
if err != nil {
return nil, types.ErrInternal
}
return treasureChest, nil
}
func (s TreasureChestImpl) Get(user *types.User, id string) (*types.TreasureChest, error) {
treasureChestMetric.WithLabelValues("get").Inc()
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
log.Error("treasureChest get: %v", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
treasureChest, err := s.db.Get(user.Id, uuid)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("treasureChest %v not found: %w", id, ErrBadRequest)
}
return nil, types.ErrInternal
}
return treasureChest, nil
}
func (s TreasureChestImpl) GetAll(user *types.User) ([]*types.TreasureChest, error) {
treasureChestMetric.WithLabelValues("get_all").Inc()
if user == nil {
return nil, ErrUnauthorized
}
treasureChests, err := s.db.GetAll(user.Id)
if err != nil {
return nil, types.ErrInternal
}
return treasureChests, nil
}
func (s TreasureChestImpl) Delete(user *types.User, idStr string) error {
treasureChestMetric.WithLabelValues("delete").Inc()
if user == nil {
return ErrUnauthorized
}
id, err := uuid.Parse(idStr)
if err != nil {
log.Error("treasureChest delete: %v", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
treasureChest, err := s.db.Get(user.Id, id)
if err != nil {
if err == db.ErrNotFound {
return fmt.Errorf("treasureChest %v not found: %w", idStr, ErrBadRequest)
}
return types.ErrInternal
}
if treasureChest.UserId != user.Id {
return types.ErrUnauthorized
}
children, err := s.db.GetAllByParentId(user.Id, treasureChest.Id)
if err != nil {
log.Error("treasureChest delete: %v", err)
return types.ErrInternal
}
if len(children) > 0 {
return fmt.Errorf("TreasureChest %v has children: %w", idStr, ErrBadRequest)
}
err = s.db.Delete(user.Id, treasureChest.Id)
if err != nil {
return types.ErrInternal
}
return nil
}