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

This commit is contained in:
2025-05-13 12:22:00 +02:00
parent d5a984e312
commit 74a44f043c
6 changed files with 106 additions and 111 deletions

View File

@@ -30,8 +30,8 @@ func NewTreasureChestSqlite(db *sqlx.DB) *TreasureChestSqlite {
func (db TreasureChestSqlite) Insert(userId uuid.UUID, treasureChest *types.TreasureChest) error { func (db TreasureChestSqlite) Insert(userId uuid.UUID, treasureChest *types.TreasureChest) error {
_, err := db.db.Exec(` _, err := db.db.Exec(`
INSERT INTO treasure_chest (id, parent_id, user_id, name, account_id, current_balance, created_at, created_by) INSERT INTO treasure_chest (id, parent_id, user_id, name, current_balance, created_at, created_by)
VALUES (?,?,?,?,?,?,?,?)`, treasureChest.Id, treasureChest.ParentId, userId, treasureChest.Name, treasureChest.AccountId, 0, treasureChest.CreatedAt, treasureChest.CreatedBy) VALUES (?,?,?,?,?,?,?)`, treasureChest.Id, treasureChest.ParentId, userId, treasureChest.Name, 0, treasureChest.CreatedAt, treasureChest.CreatedBy)
if err != nil { if err != nil {
log.Error("treasureChest Insert: %v", err) log.Error("treasureChest Insert: %v", err)
return types.ErrInternal return types.ErrInternal
@@ -47,12 +47,11 @@ func (db TreasureChestSqlite) Update(userId uuid.UUID, treasureChest *types.Trea
SET SET
parent_id = ?, parent_id = ?,
name = ?, name = ?,
account_id = ?,
current_balance = ?, current_balance = ?,
updated_at = ?, updated_at = ?,
updated_by = ? updated_by = ?
WHERE id = ? WHERE id = ?
AND user_id = ?`, treasureChest.ParentId, treasureChest.Name, treasureChest.AccountId, treasureChest.CurrentBalance, treasureChest.UpdatedAt, treasureChest.UpdatedBy, treasureChest.Id, userId) AND user_id = ?`, treasureChest.ParentId, treasureChest.Name, treasureChest.CurrentBalance, treasureChest.UpdatedAt, treasureChest.UpdatedBy, treasureChest.Id, userId)
if err != nil { if err != nil {
log.Error("treasureChest Update: %v", err) log.Error("treasureChest Update: %v", err)
return types.ErrInternal return types.ErrInternal
@@ -76,7 +75,7 @@ func (db TreasureChestSqlite) GetAll(userId uuid.UUID) ([]*types.TreasureChest,
treasureChests := make([]*types.TreasureChest, 0) treasureChests := make([]*types.TreasureChest, 0)
err := db.db.Select(&treasureChests, ` err := db.db.Select(&treasureChests, `
SELECT SELECT
id, parent_id, user_id, name, account_id, current_balance, id, parent_id, user_id, name, current_balance,
created_at, created_by, updated_at, updated_by created_at, created_by, updated_at, updated_by
FROM treasure_chest FROM treasure_chest
WHERE user_id = ? WHERE user_id = ?
@@ -94,7 +93,7 @@ func (db TreasureChestSqlite) GetAllByParentId(userId uuid.UUID, parentId uuid.U
treasureChests := make([]*types.TreasureChest, 0) treasureChests := make([]*types.TreasureChest, 0)
err := db.db.Select(&treasureChests, ` err := db.db.Select(&treasureChests, `
SELECT SELECT
id, parent_id, user_id, name, account_id, current_balance, id, parent_id, user_id, name, current_balance,
created_at, created_by, updated_at, updated_by created_at, created_by, updated_at, updated_by
FROM treasure_chest FROM treasure_chest
WHERE user_id = ? WHERE user_id = ?
@@ -113,7 +112,7 @@ func (db TreasureChestSqlite) Get(userId uuid.UUID, id uuid.UUID) (*types.Treasu
treasureChest := &types.TreasureChest{} treasureChest := &types.TreasureChest{}
err := db.db.Get(treasureChest, ` err := db.db.Get(treasureChest, `
SELECT SELECT
id, parent_id, user_id, name, account_id, current_balance, id, parent_id, user_id, name, current_balance,
created_at, created_by, updated_at, updated_by created_at, created_by, updated_at, updated_by
FROM treasure_chest FROM treasure_chest
WHERE user_id = ? WHERE user_id = ?

View File

@@ -3,14 +3,12 @@ package handler
import ( import (
"net/http" "net/http"
"spend-sparrow/handler/middleware" "spend-sparrow/handler/middleware"
"spend-sparrow/log"
"spend-sparrow/service" "spend-sparrow/service"
t "spend-sparrow/template/treasurechest" t "spend-sparrow/template/treasurechest"
"spend-sparrow/types" "spend-sparrow/types"
"spend-sparrow/utils" "spend-sparrow/utils"
"github.com/a-h/templ" "github.com/a-h/templ"
"github.com/google/uuid"
) )
type TreasureChest interface { type TreasureChest interface {
@@ -18,18 +16,16 @@ type TreasureChest interface {
} }
type TreasureChestImpl struct { type TreasureChestImpl struct {
s service.TreasureChest s service.TreasureChest
account service.Account a service.Auth
a service.Auth r *Render
r *Render
} }
func NewTreasureChest(s service.TreasureChest, account service.Account, a service.Auth, r *Render) TreasureChest { func NewTreasureChest(s service.TreasureChest, a service.Auth, r *Render) TreasureChest {
return TreasureChestImpl{ return TreasureChestImpl{
s: s, s: s,
account: account, a: a,
a: a, r: r,
r: r,
} }
} }
@@ -53,13 +49,8 @@ func (h TreasureChestImpl) handleTreasureChestPage() http.HandlerFunc {
handleError(w, r, err) handleError(w, r, err)
return return
} }
accounts, err := h.account.GetAll(user)
if err != nil {
handleError(w, r, err)
return
}
comp := t.TreasureChest(treasureChests, accounts) comp := t.TreasureChest(treasureChests)
h.r.RenderLayout(r, w, comp, user) h.r.RenderLayout(r, w, comp, user)
} }
} }
@@ -72,7 +63,7 @@ func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
return return
} }
accounts, err := h.account.GetAll(user) treasureChests, err := h.s.GetAll(user)
if err != nil { if err != nil {
handleError(w, r, err) handleError(w, r, err)
return return
@@ -80,7 +71,7 @@ func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
id := r.PathValue("id") id := r.PathValue("id")
if id == "new" { if id == "new" {
comp := t.EditTreasureChest(nil, accounts) comp := t.EditTreasureChest(nil, treasureChests)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
return return
} }
@@ -93,23 +84,14 @@ func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
var comp templ.Component var comp templ.Component
if r.URL.Query().Get("edit") == "true" { if r.URL.Query().Get("edit") == "true" {
comp = t.EditTreasureChest(treasureChest, accounts) comp = t.EditTreasureChest(treasureChest, treasureChests)
} else { } else {
comp = t.TreasureChestItem(treasureChest, find(accounts, treasureChest.AccountId)) comp = t.TreasureChestItem(treasureChest)
} }
h.r.Render(r, w, comp) 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 { func (h TreasureChestImpl) handleUpdateTreasureChest() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r) user := middleware.GetUser(r)
@@ -125,24 +107,21 @@ func (h TreasureChestImpl) handleUpdateTreasureChest() http.HandlerFunc {
id := r.PathValue("id") id := r.PathValue("id")
parentId := r.FormValue("parent-id") parentId := r.FormValue("parent-id")
name := r.FormValue("name") name := r.FormValue("name")
accountId := r.FormValue("account-id")
log.Info("accountId: %s", accountId)
if id == "new" { if id == "new" {
treasureChest, err = h.s.Add(user, parentId, name, accountId) treasureChest, err = h.s.Add(user, parentId, name)
if err != nil { if err != nil {
handleError(w, r, err) handleError(w, r, err)
return return
} }
} else { } else {
treasureChest, err = h.s.Update(user, id, parentId, name, accountId) treasureChest, err = h.s.Update(user, id, parentId, name)
if err != nil { if err != nil {
handleError(w, r, err) handleError(w, r, err)
return return
} }
} }
account, err := h.account.Get(user, accountId)
comp := t.TreasureChestItem(treasureChest, account) comp := t.TreasureChestItem(treasureChest)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
} }
} }

View File

@@ -122,7 +122,7 @@ func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
indexHandler := handler.NewIndex(authService, render) indexHandler := handler.NewIndex(authService, render)
authHandler := handler.NewAuth(authService, render) authHandler := handler.NewAuth(authService, render)
accountHandler := handler.NewAccount(accountService, authService, render) accountHandler := handler.NewAccount(accountService, authService, render)
treasureChestHandler := handler.NewTreasureChest(treasureChestService, accountService, authService, render) treasureChestHandler := handler.NewTreasureChest(treasureChestService, authService, render)
indexHandler.Handle(router) indexHandler.Handle(router)
accountHandler.Handle(router) accountHandler.Handle(router)

View File

@@ -23,8 +23,8 @@ var (
) )
type TreasureChest interface { type TreasureChest interface {
Add(user *types.User, parentId, name, accountId string) (*types.TreasureChest, error) Add(user *types.User, parentId, name string) (*types.TreasureChest, error)
Update(user *types.User, id, parentId, name, accountId string) (*types.TreasureChest, error) Update(user *types.User, id, parentId, name string) (*types.TreasureChest, error)
Get(user *types.User, id string) (*types.TreasureChest, error) Get(user *types.User, id string) (*types.TreasureChest, error)
GetAll(user *types.User) ([]*types.TreasureChest, error) GetAll(user *types.User) ([]*types.TreasureChest, error)
Delete(user *types.User, id string) error Delete(user *types.User, id string) error
@@ -48,7 +48,7 @@ func NewTreasureChest(db db.TreasureChest, account Account, random Random, clock
} }
} }
func (s TreasureChestImpl) Add(user *types.User, parentId, name, accountId string) (*types.TreasureChest, error) { func (s TreasureChestImpl) Add(user *types.User, parentId, name string) (*types.TreasureChest, error) {
treasureChestMetric.WithLabelValues("add").Inc() treasureChestMetric.WithLabelValues("add").Inc()
if user == nil { if user == nil {
@@ -64,11 +64,6 @@ func (s TreasureChestImpl) Add(user *types.User, parentId, name, accountId strin
if err != nil { if err != nil {
return nil, err 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 parentUuid := uuid.Nil
if parentId != "" { if parentId != "" {
@@ -82,18 +77,12 @@ func (s TreasureChestImpl) Add(user *types.User, parentId, name, accountId strin
parentUuid = parent.Id parentUuid = parent.Id
} }
_, err = s.account.Get(user, accountId)
if err != nil {
return nil, err
}
treasureChest := &types.TreasureChest{ treasureChest := &types.TreasureChest{
Id: newId, Id: newId,
ParentId: parentUuid, ParentId: parentUuid,
UserId: user.Id, UserId: user.Id,
Name: name, Name: name,
AccountId: accountUuid,
CurrentBalance: 0, CurrentBalance: 0,
@@ -116,7 +105,7 @@ func (s TreasureChestImpl) Add(user *types.User, parentId, name, accountId strin
return savedtreasureChest, nil return savedtreasureChest, nil
} }
func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name, accountId string) (*types.TreasureChest, error) { func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string) (*types.TreasureChest, error) {
treasureChestMetric.WithLabelValues("update").Inc() treasureChestMetric.WithLabelValues("update").Inc()
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, ErrUnauthorized
@@ -125,11 +114,6 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name, accou
if err != nil { if err != nil {
return nil, err 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) id, err := uuid.Parse(idStr)
if err != nil { if err != nil {
log.Error("treasureChest update: %v", err) log.Error("treasureChest update: %v", err)
@@ -143,10 +127,6 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name, accou
} }
return nil, types.ErrInternal return nil, types.ErrInternal
} }
_, err = s.account.Get(user, accountId)
if err != nil {
return nil, err
}
parentUuid := uuid.Nil parentUuid := uuid.Nil
if parentId != "" { if parentId != "" {
@@ -157,13 +137,18 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name, accou
if parent.ParentId != uuid.Nil { if parent.ParentId != uuid.Nil {
return nil, fmt.Errorf("Only a depth of 1 allowed: %w", ErrBadRequest) return nil, fmt.Errorf("Only a depth of 1 allowed: %w", ErrBadRequest)
} }
children, err := s.db.GetAllByParentId(user.Id, treasureChest.Id)
if len(children) > 0 {
return nil, fmt.Errorf("Only a depth of 1 allowed: %w", ErrBadRequest)
}
parentUuid = parent.Id parentUuid = parent.Id
} }
timestamp := s.clock.Now() timestamp := s.clock.Now()
treasureChest.Name = name treasureChest.Name = name
treasureChest.ParentId = parentUuid treasureChest.ParentId = parentUuid
treasureChest.AccountId = accountUuid
treasureChest.UpdatedAt = &timestamp treasureChest.UpdatedAt = &timestamp
treasureChest.UpdatedBy = &user.Id treasureChest.UpdatedBy = &user.Id

View File

@@ -1,14 +1,47 @@
package treasurechest package treasurechest
import "fmt" import "fmt"
import "spend-sparrow/template/svg" import "spend-sparrow/template/svg"
import "spend-sparrow/types" import "spend-sparrow/types"
import "github.com/google/uuid" import "github.com/google/uuid"
// TODO: Incorporate parent-id and hirarchical view func sortTree(nodes []*types.TreasureChest) []*types.TreasureChest {
xxx
templ TreasureChest(treasureChests []*types.TreasureChest, accounts []*types.Account) { var (
roots []*types.TreasureChest
result []*types.TreasureChest
)
children := make(map[uuid.UUID][]*types.TreasureChest)
for _, node := range nodes {
if node.ParentId == uuid.Nil {
roots = append(roots, node)
} else {
children[node.ParentId] = append(children[node.ParentId], node)
}
}
for _, root := range roots {
result = append(result, root)
result = append(result, children[root.Id]...)
}
return result
}
func compareStrings(a, b string) int {
if a < b {
return -1
}
if a > b {
return 1
}
return 0
}
templ TreasureChest(treasureChests []*types.TreasureChest) {
<div class="max-w-6xl mt-10 mx-auto"> <div class="max-w-6xl mt-10 mx-auto">
<button <button
hx-get="/treasurechest/new" hx-get="/treasurechest/new"
@@ -17,50 +50,34 @@ templ TreasureChest(treasureChests []*types.TreasureChest, accounts []*types.Acc
class="ml-auto button button-primary px-2 flex-1 flex items-center gap-2 justify-center" class="ml-auto button button-primary px-2 flex-1 flex items-center gap-2 justify-center"
> >
@svg.Plus() @svg.Plus()
<p class="">New Treasure Chest</p> <p>New Treasure Chest</p>
</button> </button>
<div id="treasurechest-items" class="my-6 flex flex-col items-center"> <div id="treasurechest-items" class="my-6 flex flex-col items-center">
for _, treasureChest := range treasureChests { for _, treasureChest := range sortTree(treasureChests) {
@TreasureChestItem(treasureChest, find(accounts, treasureChest.AccountId)) @TreasureChestItem(treasureChest)
} }
</div> </div>
</div> </div>
} }
func find(accounts []*types.Account, id uuid.UUID) *types.Account { templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.TreasureChest) {
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 ( var (
id string id string
name string name string
accountId uuid.UUID parentId uuid.UUID
cancelUrl string cancelUrl string
) )
if treasureChest == nil { if treasureChest == nil {
id = "new" id = "new"
name = "" name = ""
accountId = uuid.Nil parentId = uuid.Nil
cancelUrl = "/empty" cancelUrl = "/empty"
} else { } else {
id = treasureChest.Id.String() id = treasureChest.Id.String()
name = treasureChest.Name name = treasureChest.Name
accountId = treasureChest.AccountId parentId = treasureChest.ParentId
cancelUrl = "/treasurechest/" + id cancelUrl = "/treasurechest/" + id
} }
}} }}
@@ -79,13 +96,13 @@ templ EditTreasureChest(treasureChest *types.TreasureChest, accounts []*types.Ac
placeholder="Treasure Chest Name" placeholder="Treasure Chest Name"
class="bg-white input" class="bg-white input"
/> />
<select name="account-id" class="mr-auto bg-white input"> <select name="parent-id" class="mr-auto bg-white input">
<option value="" class="text-gray-500">- Please select Account -</option> <option value="" class="text-gray-500">-</option>
for _, account := range accounts { for _, parent := range filterNoChildNoSelf(parents, id) {
<option <option
selected?={ accountId == account.Id } selected?={ parentId == parent.Id }
value={ account.Id.String() } value={ parent.Id.String() }
>{ account.Name }</option> >{ parent.Name }</option>
} }
</select> </select>
<button type="submit" class="button button-neglect px-1 flex items-center gap-2"> <button type="submit" class="button button-neglect px-1 flex items-center gap-2">
@@ -109,17 +126,18 @@ templ EditTreasureChest(treasureChest *types.TreasureChest, accounts []*types.Ac
</div> </div>
} }
templ TreasureChestItem(treasureChest *types.TreasureChest, account *types.Account) { templ TreasureChestItem(treasureChest *types.TreasureChest) {
{{ {{
var identation string var identation string
if treasureChest.Name == "Versicherung" { if treasureChest.ParentId != uuid.Nil {
identation = " ml-36" identation = " mt-2 ml-36"
} else {
identation = " mt-8"
} }
}} }}
<div id="treasurechest" class={ "border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg" + identation }> <div id="treasurechest" class={ "border-1 border-gray-300 w-full p-4 bg-gray-50 rounded-lg" + identation }>
<div class="text-xl flex justify-end items-center gap-4"> <div class="text-xl flex justify-end items-center gap-4">
<p class="mr-20">{ treasureChest.Name }</p> <p class="mr-auto">{ 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> <p class="mr-20 text-green-700">{ displayBalance(treasureChest.CurrentBalance) }</p>
<button <button
hx-get={ "/treasurechest/" + treasureChest.Id.String() + "?edit=true" } hx-get={ "/treasurechest/" + treasureChest.Id.String() + "?edit=true" }
@@ -147,6 +165,18 @@ templ TreasureChestItem(treasureChest *types.TreasureChest, account *types.Accou
</div> </div>
} }
func filterNoChildNoSelf(nodes []*types.TreasureChest, selfId string) []*types.TreasureChest {
var result []*types.TreasureChest
for _, node := range nodes {
if node.ParentId == uuid.Nil && node.Id.String() != selfId {
result = append(result, node)
}
}
return result
}
func displayBalance(balance int64) string { func displayBalance(balance int64) string {
euros := float64(balance) / 100 euros := float64(balance) / 100

View File

@@ -7,14 +7,16 @@ import (
) )
// The TreasureChest is a fictional account. // The TreasureChest is a fictional account.
// The money it "holds" is actually in the linked Account // The money it "holds" distributed across all accounts
//
// At the time of writing this, linking it to a specific account doesn't really make sense
// Imagne a TreasureChest for free time activities, where some money is spend in cash and some other with credit card
type TreasureChest struct { type TreasureChest struct {
Id uuid.UUID Id uuid.UUID
ParentId uuid.UUID `db:"parent_id"` ParentId uuid.UUID `db:"parent_id"`
UserId uuid.UUID `db:"user_id"` UserId uuid.UUID `db:"user_id"`
AccountId uuid.UUID `db:"account_id"` Name string
Name string
CurrentBalance int64 `db:"current_balance"` CurrentBalance int64 `db:"current_balance"`