feat(treasurechest): #64 implement hirarchical treasure chests
Some checks failed
Build Docker Image / Build-Docker-Image (push) Failing after 1m0s

This commit is contained in:
2025-05-12 23:37:47 +02:00
parent df022c9077
commit d5a984e312
18 changed files with 813 additions and 62 deletions

152
db/treasure_chest.go Normal file
View File

@@ -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
}

View File

@@ -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

166
handler/treasure_chest.go Normal file
View File

@@ -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
}
}
}

14
main.go
View File

@@ -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)

View File

@@ -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;

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: 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
}
}

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,

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)
})

View File

@@ -8,7 +8,7 @@ type Clock interface {
type ClockImpl struct{}
func NewClockImpl() Clock {
func NewClock() Clock {
return &ClockImpl{}
}

13
service/default.go Normal file
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
}
}

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}
}

View File

@@ -19,7 +19,7 @@ type Random interface {
type RandomImpl struct {
}
func NewRandomImpl() *RandomImpl {
func NewRandom() *RandomImpl {
return &RandomImpl{}
}

253
service/treasure_chest.go Normal file
View File

@@ -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 = &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
}

View File

@@ -52,6 +52,7 @@ templ EditAccount(account *types.Account) {
name="name"
type="text"
value={ name }
placeholder="Account Name"
class="mr-auto bg-white input"
/>
<button type="submit" class="button button-neglect px-1 flex items-center gap-2">

View File

@@ -37,6 +37,7 @@ templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path str
if loggedIn {
<a class={ layoutLinkClass(path == "/") } href="/">Dashboard</a>
<a class={ layoutLinkClass(path == "/transaction") } href="/transaction">Transaction</a>
<a class={ layoutLinkClass(path == "/treasurechest") } href="/treasurechest">Treasure Chest</a>
<a class={ layoutLinkClass(path == "/account") } href="/account">Account</a>
}
<div class="ml-auto">

View File

@@ -0,0 +1 @@
package treasurechest

View File

@@ -0,0 +1,154 @@
package treasurechest
import "fmt"
import "spend-sparrow/template/svg"
import "spend-sparrow/types"
import "github.com/google/uuid"
// TODO: Incorporate parent-id and hirarchical view
xxx
templ TreasureChest(treasureChests []*types.TreasureChest, accounts []*types.Account) {
<div class="max-w-6xl mt-10 mx-auto">
<button
hx-get="/treasurechest/new"
hx-target="#treasurechest-items"
hx-swap="afterbegin"
class="ml-auto button button-primary px-2 flex-1 flex items-center gap-2 justify-center"
>
@svg.Plus()
<p class="">New Treasure Chest</p>
</button>
<div id="treasurechest-items" class="my-6 flex flex-col items-center">
for _, treasureChest := range treasureChests {
@TreasureChestItem(treasureChest, find(accounts, treasureChest.AccountId))
}
</div>
</div>
}
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
}
}}
<div id="treasurechest" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg">
<form
hx-post={ "/treasurechest/" + id }
hx-target="closest #treasurechest"
hx-swap="outerHTML"
class="text-xl flex justify-end gap-4 items-center"
>
<input
autofocus
name="name"
type="text"
value={ name }
placeholder="Treasure Chest Name"
class="bg-white input"
/>
<select name="account-id" class="mr-auto bg-white input">
<option value="" class="text-gray-500">- Please select Account -</option>
for _, account := range accounts {
<option
selected?={ accountId == account.Id }
value={ account.Id.String() }
>{ account.Name }</option>
}
</select>
<button type="submit" class="button button-neglect px-1 flex items-center gap-2">
@svg.Save()
<span>
Save
</span>
</button>
<button
hx-get={ cancelUrl }
hx-target="closest #treasurechest"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Cancel()
<span>
Cancel
</span>
</button>
</form>
</div>
}
templ TreasureChestItem(treasureChest *types.TreasureChest, account *types.Account) {
{{
var identation string
if treasureChest.Name == "Versicherung" {
identation = " ml-36"
}
}}
<div id="treasurechest" class={ "border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg" + identation }>
<div class="text-xl flex justify-end items-center gap-4">
<p class="mr-20">{ treasureChest.Name }</p>
<p class="mr-auto text-sm text-gray-500">{ account.Name }</p>
<p class="mr-20 text-green-700">{ displayBalance(treasureChest.CurrentBalance) }</p>
<button
hx-get={ "/treasurechest/" + treasureChest.Id.String() + "?edit=true" }
hx-target="closest #treasurechest"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Edit()
<span>
Edit
</span>
</button>
<button
hx-delete={ "/treasurechest/" + treasureChest.Id.String() }
hx-target="closest #treasurechest"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Delete()
<span>
Delete
</span>
</button>
</div>
</div>
}
func displayBalance(balance int64) string {
euros := float64(balance) / 100
return fmt.Sprintf("%.2f €", euros)
}

View File

@@ -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