diff --git a/db/treasure_chest.go b/db/treasure_chest.go new file mode 100644 index 0000000..b0f4566 --- /dev/null +++ b/db/treasure_chest.go @@ -0,0 +1,152 @@ +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 TreasureChest interface { + Insert(userId uuid.UUID, treasureChest *types.TreasureChest) error + Update(userId uuid.UUID, treasureChest *types.TreasureChest) error + GetAll(userId uuid.UUID) ([]*types.TreasureChest, error) + GetAllByParentId(userId uuid.UUID, parentId uuid.UUID) ([]*types.TreasureChest, error) + Get(userId uuid.UUID, id uuid.UUID) (*types.TreasureChest, error) + Delete(userId uuid.UUID, id uuid.UUID) error +} + +type TreasureChestSqlite struct { + db *sqlx.DB +} + +func NewTreasureChestSqlite(db *sqlx.DB) *TreasureChestSqlite { + return &TreasureChestSqlite{db: db} +} + +func (db TreasureChestSqlite) Insert(userId uuid.UUID, treasureChest *types.TreasureChest) error { + + _, err := db.db.Exec(` + INSERT INTO treasure_chest (id, parent_id, user_id, name, account_id, current_balance, created_at, created_by) + VALUES (?,?,?,?,?,?,?,?)`, treasureChest.Id, treasureChest.ParentId, userId, treasureChest.Name, treasureChest.AccountId, 0, treasureChest.CreatedAt, treasureChest.CreatedBy) + if err != nil { + log.Error("treasureChest Insert: %v", err) + return types.ErrInternal + } + + return nil +} + +func (db TreasureChestSqlite) Update(userId uuid.UUID, treasureChest *types.TreasureChest) error { + + r, err := db.db.Exec(` + UPDATE treasure_chest + SET + parent_id = ?, + name = ?, + account_id = ?, + current_balance = ?, + updated_at = ?, + updated_by = ? + WHERE id = ? + AND user_id = ?`, treasureChest.ParentId, treasureChest.Name, treasureChest.AccountId, treasureChest.CurrentBalance, treasureChest.UpdatedAt, treasureChest.UpdatedBy, treasureChest.Id, userId) + if err != nil { + log.Error("treasureChest Update: %v", err) + return types.ErrInternal + } + rows, err := r.RowsAffected() + if err != nil { + log.Error("treasureChest Update: %v", err) + return types.ErrInternal + } + + if rows == 0 { + log.Info("treasureChest Update: not found") + return ErrNotFound + } + + return nil +} + +func (db TreasureChestSqlite) GetAll(userId uuid.UUID) ([]*types.TreasureChest, error) { + + treasureChests := make([]*types.TreasureChest, 0) + err := db.db.Select(&treasureChests, ` + SELECT + id, parent_id, user_id, name, account_id, current_balance, + created_at, created_by, updated_at, updated_by + FROM treasure_chest + WHERE user_id = ? + ORDER BY name`, userId) + if err != nil { + log.Error("treasureChest GetAll: %v", err) + return nil, types.ErrInternal + } + + return treasureChests, nil +} + +func (db TreasureChestSqlite) GetAllByParentId(userId uuid.UUID, parentId uuid.UUID) ([]*types.TreasureChest, error) { + + treasureChests := make([]*types.TreasureChest, 0) + err := db.db.Select(&treasureChests, ` + SELECT + id, parent_id, user_id, name, account_id, current_balance, + created_at, created_by, updated_at, updated_by + FROM treasure_chest + WHERE user_id = ? + AND parent_id = ? + ORDER BY name`, userId, parentId) + if err != nil { + log.Error("treasureChest GetAll: %v", err) + return nil, types.ErrInternal + } + + return treasureChests, nil +} + +func (db TreasureChestSqlite) Get(userId uuid.UUID, id uuid.UUID) (*types.TreasureChest, error) { + + treasureChest := &types.TreasureChest{} + err := db.db.Get(treasureChest, ` + SELECT + id, parent_id, user_id, name, account_id, current_balance, + created_at, created_by, updated_at, updated_by + FROM treasure_chest + WHERE user_id = ? + AND id = ?`, userId, id) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + log.Error("treasureChest Get: %v", err) + return nil, types.ErrInternal + } + + return treasureChest, nil +} + +func (db TreasureChestSqlite) Delete(userId uuid.UUID, id uuid.UUID) error { + + res, err := db.db.Exec("DELETE FROM treasure_chest WHERE id = ? and user_id = ?", id, userId) + if err != nil { + log.Error("treasureChest Delete: %v", err) + return types.ErrInternal + } + + rows, err := res.RowsAffected() + if err != nil { + log.Error("treasureChest Delete: %v", err) + return types.ErrInternal + } + + if rows == 0 { + log.Info("treasureChest Delete: not found") + return ErrNotFound + } + + return nil +} diff --git a/handler/account.go b/handler/account.go index 49d451e..c7294ed 100644 --- a/handler/account.go +++ b/handler/account.go @@ -1,7 +1,6 @@ package handler import ( - "fmt" "net/http" "spend-sparrow/handler/middleware" "spend-sparrow/service" @@ -10,7 +9,6 @@ import ( "spend-sparrow/utils" "github.com/a-h/templ" - "github.com/google/uuid" ) type Account interface { @@ -65,19 +63,13 @@ func (h AccountImpl) handleAccountItemComp() http.HandlerFunc { return } - idStr := r.PathValue("id") - if idStr == "new" { + id := r.PathValue("id") + if id == "new" { comp := t.EditAccount(nil) h.r.Render(r, w, comp) return } - id, err := uuid.Parse(idStr) - if err != nil { - handleError(w, r, fmt.Errorf("could not parse Id: %w", service.ErrBadRequest)) - return - } - account, err := h.s.Get(user, id) if err != nil { handleError(w, r, err) @@ -106,20 +98,15 @@ func (h AccountImpl) handleUpdateAccount() http.HandlerFunc { account *types.Account err error ) - idStr := r.PathValue("id") + id := r.PathValue("id") name := r.FormValue("name") - if idStr == "new" { + if id == "new" { account, err = h.s.Add(user, name) if err != nil { handleError(w, r, err) return } } else { - id, err := uuid.Parse(idStr) - if err != nil { - handleError(w, r, fmt.Errorf("could not parse Id: %w", service.ErrBadRequest)) - return - } account, err = h.s.Update(user, id, name) if err != nil { handleError(w, r, err) @@ -140,13 +127,9 @@ func (h AccountImpl) handleDeleteAccount() http.HandlerFunc { return } - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - handleError(w, r, fmt.Errorf("could not parse Id: %w", service.ErrBadRequest)) - return - } + id := r.PathValue("id") - err = h.s.Delete(user, id) + err := h.s.Delete(user, id) if err != nil { handleError(w, r, err) return diff --git a/handler/treasure_chest.go b/handler/treasure_chest.go new file mode 100644 index 0000000..d69890e --- /dev/null +++ b/handler/treasure_chest.go @@ -0,0 +1,166 @@ +package handler + +import ( + "net/http" + "spend-sparrow/handler/middleware" + "spend-sparrow/log" + "spend-sparrow/service" + t "spend-sparrow/template/treasurechest" + "spend-sparrow/types" + "spend-sparrow/utils" + + "github.com/a-h/templ" + "github.com/google/uuid" +) + +type TreasureChest interface { + Handle(router *http.ServeMux) +} + +type TreasureChestImpl struct { + s service.TreasureChest + account service.Account + a service.Auth + r *Render +} + +func NewTreasureChest(s service.TreasureChest, account service.Account, a service.Auth, r *Render) TreasureChest { + return TreasureChestImpl{ + s: s, + account: account, + a: a, + r: r, + } +} + +func (h TreasureChestImpl) Handle(r *http.ServeMux) { + r.Handle("GET /treasurechest", h.handleTreasureChestPage()) + r.Handle("GET /treasurechest/{id}", h.handleTreasureChestItemComp()) + r.Handle("POST /treasurechest/{id}", h.handleUpdateTreasureChest()) + r.Handle("DELETE /treasurechest/{id}", h.handleDeleteTreasureChest()) +} + +func (h TreasureChestImpl) handleTreasureChestPage() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + treasureChests, err := h.s.GetAll(user) + if err != nil { + handleError(w, r, err) + return + } + accounts, err := h.account.GetAll(user) + if err != nil { + handleError(w, r, err) + return + } + + comp := t.TreasureChest(treasureChests, accounts) + h.r.RenderLayout(r, w, comp, user) + } +} + +func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + accounts, err := h.account.GetAll(user) + if err != nil { + handleError(w, r, err) + return + } + + id := r.PathValue("id") + if id == "new" { + comp := t.EditTreasureChest(nil, accounts) + h.r.Render(r, w, comp) + return + } + + treasureChest, 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.EditTreasureChest(treasureChest, accounts) + } else { + comp = t.TreasureChestItem(treasureChest, find(accounts, treasureChest.AccountId)) + } + h.r.Render(r, w, comp) + } +} + +func find(accounts []*types.Account, id uuid.UUID) *types.Account { + for _, account := range accounts { + if account.Id == id { + return account + } + } + return nil +} + +func (h TreasureChestImpl) handleUpdateTreasureChest() 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 ( + treasureChest *types.TreasureChest + err error + ) + id := r.PathValue("id") + parentId := r.FormValue("parent-id") + name := r.FormValue("name") + accountId := r.FormValue("account-id") + log.Info("accountId: %s", accountId) + if id == "new" { + treasureChest, err = h.s.Add(user, parentId, name, accountId) + if err != nil { + handleError(w, r, err) + return + } + } else { + treasureChest, err = h.s.Update(user, id, parentId, name, accountId) + if err != nil { + handleError(w, r, err) + return + } + } + account, err := h.account.Get(user, accountId) + + comp := t.TreasureChestItem(treasureChest, account) + h.r.Render(r, w, comp) + } +} + +func (h TreasureChestImpl) handleDeleteTreasureChest() 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/main.go b/main.go index 3649a8c..0d16f65 100644 --- a/main.go +++ b/main.go @@ -108,21 +108,25 @@ func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler { authDb := db.NewAuthSqlite(d) accountDb := db.NewAccountSqlite(d) + treasureChestDb := db.NewTreasureChestSqlite(d) - randomService := service.NewRandomImpl() - clockService := service.NewClockImpl() - mailService := service.NewMailImpl(serverSettings) + randomService := service.NewRandom() + clockService := service.NewClock() + mailService := service.NewMail(serverSettings) - authService := service.NewAuthImpl(authDb, randomService, clockService, mailService, serverSettings) - accountService := service.NewAccountImpl(accountDb, randomService, clockService, serverSettings) + authService := service.NewAuth(authDb, randomService, clockService, mailService, serverSettings) + accountService := service.NewAccount(accountDb, randomService, clockService, serverSettings) + treasureChestService := service.NewTreasureChest(treasureChestDb, accountService, randomService, clockService, serverSettings) render := handler.NewRender() indexHandler := handler.NewIndex(authService, render) authHandler := handler.NewAuth(authService, render) accountHandler := handler.NewAccount(accountService, authService, render) + treasureChestHandler := handler.NewTreasureChest(treasureChestService, accountService, authService, render) indexHandler.Handle(router) accountHandler.Handle(router) + treasureChestHandler.Handle(router) authHandler.Handle(router) // Serve static files (CSS, JS and images) diff --git a/migration/003_treasure_chest.up.sql b/migration/003_treasure_chest.up.sql new file mode 100644 index 0000000..718f80e --- /dev/null +++ b/migration/003_treasure_chest.up.sql @@ -0,0 +1,17 @@ + +CREATE TABLE treasure_chest ( + id TEXT NOT NULL UNIQUE PRIMARY KEY, + parent_id TEXT, + user_id TEXT NOT NULL, + + name TEXT NOT NULL, + account_id TEXT NOT NULL, + + current_balance int64 NOT NULL, + + created_at DATETIME NOT NULL, + created_by TEXT NOT NULL, + updated_at DATETIME, + updated_by TEXT +) WITHOUT ROWID; + diff --git a/service/account.go b/service/account.go index 443ff9b..21e424b 100644 --- a/service/account.go +++ b/service/account.go @@ -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: NewRandom(), 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 - } -} diff --git a/service/auth.go b/service/auth.go index 2ca83c5..27ba7c9 100644 --- a/service/auth.go +++ b/service/auth.go @@ -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, diff --git a/service/auth_test.go b/service/auth_test.go index 2e0f967..aab512a 100644 --- a/service/auth_test.go +++ b/service/auth_test.go @@ -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) }) diff --git a/service/clock.go b/service/clock.go index cf7cae9..cfbfd2a 100644 --- a/service/clock.go +++ b/service/clock.go @@ -8,7 +8,7 @@ type Clock interface { type ClockImpl struct{} -func NewClockImpl() Clock { +func NewClock() Clock { return &ClockImpl{} } diff --git a/service/default.go b/service/default.go new file mode 100644 index 0000000..08a765a --- /dev/null +++ b/service/default.go @@ -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 + } +} diff --git a/service/mail.go b/service/mail.go index fb11c79..008b636 100644 --- a/service/mail.go +++ b/service/mail.go @@ -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} } diff --git a/service/random_generator.go b/service/random_generator.go index 8e768ed..9dbc269 100644 --- a/service/random_generator.go +++ b/service/random_generator.go @@ -19,7 +19,7 @@ type Random interface { type RandomImpl struct { } -func NewRandomImpl() *RandomImpl { +func NewRandom() *RandomImpl { return &RandomImpl{} } diff --git a/service/treasure_chest.go b/service/treasure_chest.go new file mode 100644 index 0000000..5b016a7 --- /dev/null +++ b/service/treasure_chest.go @@ -0,0 +1,253 @@ +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, accountId string) (*types.TreasureChest, error) + Update(user *types.User, id, parentId, name, accountId 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 + account Account + clock Clock + random Random + settings *types.Settings +} + +func NewTreasureChest(db db.TreasureChest, account Account, random Random, clock Clock, settings *types.Settings) TreasureChest { + return TreasureChestImpl{ + db: db, + account: account, + clock: clock, + random: NewRandom(), + settings: settings, + } +} + +func (s TreasureChestImpl) Add(user *types.User, parentId, name, accountId 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 + } + accountUuid, err := uuid.Parse(accountId) + if err != nil { + log.Error("treasureChest add: %v", err) + return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest) + } + + 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 + } + + _, err = s.account.Get(user, accountId) + if err != nil { + return nil, err + } + + treasureChest := &types.TreasureChest{ + Id: newId, + ParentId: parentUuid, + UserId: user.Id, + + Name: name, + AccountId: accountUuid, + + 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, accountId string) (*types.TreasureChest, error) { + treasureChestMetric.WithLabelValues("update").Inc() + if user == nil { + return nil, ErrUnauthorized + } + err := validateString(name) + if err != nil { + return nil, err + } + accountUuid, err := uuid.Parse(accountId) + if err != nil { + log.Error("treasureChest update: %v", err) + return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest) + } + 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 + } + _, err = s.account.Get(user, accountId) + 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 + } + + timestamp := s.clock.Now() + treasureChest.Name = name + treasureChest.ParentId = parentUuid + treasureChest.AccountId = accountUuid + treasureChest.UpdatedAt = ×tamp + 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 +} diff --git a/template/account/account.templ b/template/account/account.templ index b0fb7b9..5d9c595 100644 --- a/template/account/account.templ +++ b/template/account/account.templ @@ -52,6 +52,7 @@ templ EditAccount(account *types.Account) { name="name" type="text" value={ name } + placeholder="Account Name" class="mr-auto bg-white input" /> +
+ for _, treasureChest := range treasureChests { + @TreasureChestItem(treasureChest, find(accounts, treasureChest.AccountId)) + } +
+ +} + +func find(accounts []*types.Account, id uuid.UUID) *types.Account { + for _, account := range accounts { + if account.Id == id { + return account + } + } + return nil +} + +func selected(accountId uuid.UUID, treasureChest *types.TreasureChest) bool { + if treasureChest != nil && accountId == treasureChest.AccountId { + return true + } + return false +} + +templ EditTreasureChest(treasureChest *types.TreasureChest, accounts []*types.Account) { + {{ + var ( + id string + name string + accountId uuid.UUID + cancelUrl string + ) + + if treasureChest == nil { + id = "new" + name = "" + accountId = uuid.Nil + cancelUrl = "/empty" + } else { + id = treasureChest.Id.String() + name = treasureChest.Name + accountId = treasureChest.AccountId + cancelUrl = "/treasurechest/" + id + } + }} +
+
+ + + + +
+
+} + +templ TreasureChestItem(treasureChest *types.TreasureChest, account *types.Account) { + {{ + var identation string + if treasureChest.Name == "Versicherung" { + identation = " ml-36" + } + }} +
+
+

{ treasureChest.Name }

+

{ account.Name }

+

{ displayBalance(treasureChest.CurrentBalance) }

+ + +
+
+} + +func displayBalance(balance int64) string { + + euros := float64(balance) / 100 + return fmt.Sprintf("%.2f €", euros) +} diff --git a/types/treasure.go b/types/treasure_chest.go similarity index 83% rename from types/treasure.go rename to types/treasure_chest.go index 32f491f..4a41d94 100644 --- a/types/treasure.go +++ b/types/treasure_chest.go @@ -9,8 +9,9 @@ import ( // The TreasureChest is a fictional account. // The money it "holds" is actually in the linked Account type TreasureChest struct { - Id uuid.UUID - UserId uuid.UUID `db:"user_id"` + Id uuid.UUID + ParentId uuid.UUID `db:"parent_id"` + UserId uuid.UUID `db:"user_id"` AccountId uuid.UUID `db:"account_id"` Name string