feat: move treasure_chest to seperate module
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m18s

This commit is contained in:
2025-12-27 10:18:20 +01:00
parent 0325fe101c
commit 28113d27d0
13 changed files with 123 additions and 112 deletions

View File

@@ -0,0 +1,196 @@
package treasure_chest
import (
"net/http"
"spend-sparrow/internal/core"
"spend-sparrow/internal/service"
tr "spend-sparrow/internal/template/transaction_recurring"
"spend-sparrow/internal/treasure_chest_types"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
"github.com/a-h/templ"
"github.com/google/uuid"
)
type Handler interface {
Handle(router *http.ServeMux)
}
type HandlerImpl struct {
s Service
transactionRecurring service.TransactionRecurring
r *core.Render
}
func NewHandler(s Service, transactionRecurring service.TransactionRecurring, r *core.Render) Handler {
return HandlerImpl{
s: s,
transactionRecurring: transactionRecurring,
r: r,
}
}
func (h HandlerImpl) Handle(r *http.ServeMux) {
r.Handle("GET /treasurechest", h.handleHandlerPage())
r.Handle("GET /treasurechest/{id}", h.handleHandlerItemComp())
r.Handle("POST /treasurechest/{id}", h.handleUpdateHandler())
r.Handle("DELETE /treasurechest/{id}", h.handleDeleteHandler())
}
func (h HandlerImpl) handleHandlerPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
treasureChests, err := h.s.GetAll(r.Context(), user)
if err != nil {
core.HandleError(w, r, err)
return
}
transactionsRecurring, err := h.transactionRecurring.GetAll(r.Context(), user)
if err != nil {
core.HandleError(w, r, err)
return
}
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
comp := TreasureChestComp(treasureChests, monthlySums)
h.r.RenderLayout(r, w, comp, user)
}
}
func (h HandlerImpl) handleHandlerItemComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
treasureChests, err := h.s.GetAll(r.Context(), user)
if err != nil {
core.HandleError(w, r, err)
return
}
id := r.PathValue("id")
if id == "new" {
comp := EditTreasureChest(nil, treasureChests, nil)
h.r.Render(r, w, comp)
return
}
treasureChest, err := h.s.Get(r.Context(), user, id)
if err != nil {
core.HandleError(w, r, err)
return
}
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
if err != nil {
core.HandleError(w, r, err)
return
}
transactionsRec := tr.TransactionRecurringItems(transactionsRecurring, "", "", treasureChest.Id.String())
var comp templ.Component
if r.URL.Query().Get("edit") == "true" {
comp = EditTreasureChest(treasureChest, treasureChests, transactionsRec)
} else {
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
comp = TreasureChestItem(treasureChest, monthlySums)
}
h.r.Render(r, w, comp)
}
}
func (h HandlerImpl) handleUpdateHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
var (
treasureChest *treasure_chest_types.TreasureChest
err error
)
id := r.PathValue("id")
parentId := r.FormValue("parent-id")
name := r.FormValue("name")
if id == "new" {
treasureChest, err = h.s.Add(r.Context(), user, parentId, name)
if err != nil {
core.HandleError(w, r, err)
return
}
} else {
treasureChest, err = h.s.Update(r.Context(), user, id, parentId, name)
if err != nil {
core.HandleError(w, r, err)
return
}
}
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
if err != nil {
core.HandleError(w, r, err)
return
}
treasureChests := make([]*treasure_chest_types.TreasureChest, 1)
treasureChests[0] = treasureChest
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
comp := TreasureChestItem(treasureChest, monthlySums)
h.r.Render(r, w, comp)
}
}
func (h HandlerImpl) handleDeleteHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
id := r.PathValue("id")
err := h.s.Delete(r.Context(), user, id)
if err != nil {
core.HandleError(w, r, err)
return
}
}
}
func (h HandlerImpl) calculateMonthlySums(
treasureChests []*treasure_chest_types.TreasureChest,
transactionsRecurring []*types.TransactionRecurring,
) map[uuid.UUID]int64 {
monthlySums := make(map[uuid.UUID]int64)
for _, tc := range treasureChests {
monthlySums[tc.Id] = 0
}
for _, t := range transactionsRecurring {
if t.TreasureChestId != nil && t.Value > 0 && t.IntervalMonths > 0 {
monthlySums[*t.TreasureChestId] += t.Value / t.IntervalMonths
}
}
return monthlySums
}

View File

@@ -0,0 +1,322 @@
package treasure_chest
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core"
"spend-sparrow/internal/treasure_chest_types"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type Service interface {
Add(ctx context.Context, user *auth_types.User, parentId, name string) (*treasure_chest_types.TreasureChest, error)
Update(ctx context.Context, user *auth_types.User, id, parentId, name string) (*treasure_chest_types.TreasureChest, error)
Get(ctx context.Context, user *auth_types.User, id string) (*treasure_chest_types.TreasureChest, error)
GetAll(ctx context.Context, user *auth_types.User) ([]*treasure_chest_types.TreasureChest, error)
Delete(ctx context.Context, user *auth_types.User, id string) error
}
type ServiceImpl struct {
db *sqlx.DB
clock core.Clock
random core.Random
}
func NewService(db *sqlx.DB, random core.Random, clock core.Clock) Service {
return ServiceImpl{
db: db,
clock: clock,
random: random,
}
}
func (s ServiceImpl) Add(ctx context.Context, user *auth_types.User, parentId, name string) (*treasure_chest_types.TreasureChest, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
newId, err := s.random.UUID(ctx)
if err != nil {
return nil, core.ErrInternal
}
err = core.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", core.ErrBadRequest)
}
parentUuid = &parent.Id
}
treasureChest := &treasure_chest_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 = core.TransformAndLogDbError(ctx, "treasureChest Insert", r, err)
if err != nil {
return nil, err
}
return treasureChest, nil
}
func (s ServiceImpl) Update(ctx context.Context, user *auth_types.User, idStr, parentId, name string) (*treasure_chest_types.TreasureChest, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
err := core.ValidateString(name, "name")
if err != nil {
return nil, err
}
id, err := uuid.Parse(idStr)
if err != nil {
slog.ErrorContext(ctx, "treasureChest update", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
}
tx, err := s.db.BeginTxx(ctx, nil)
err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
treasureChest := &treasure_chest_types.TreasureChest{}
err = tx.GetContext(ctx, treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, id)
err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
if err != nil {
if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("treasureChest %v not found: %w", idStr, err)
}
return nil, core.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 = core.TransformAndLogDbError(ctx, "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", core.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 = core.TransformAndLogDbError(ctx, "treasureChest Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
if err != nil {
return nil, err
}
return treasureChest, nil
}
func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, id string) (*treasure_chest_types.TreasureChest, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.ErrorContext(ctx, "treasureChest get", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
}
var treasureChest treasure_chest_types.TreasureChest
err = s.db.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = core.TransformAndLogDbError(ctx, "treasureChest Get", nil, err)
if err != nil {
if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("treasureChest %v not found: %w", id, err)
}
return nil, core.ErrInternal
}
return &treasureChest, nil
}
func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*treasure_chest_types.TreasureChest, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
treasureChests := make([]*treasure_chest_types.TreasureChest, 0)
err := s.db.SelectContext(ctx, &treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id)
err = core.TransformAndLogDbError(ctx, "treasureChest GetAll", nil, err)
if err != nil {
return nil, err
}
return SortTreasureChests(treasureChests), nil
}
func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, idStr string) error {
if user == nil {
return core.ErrUnauthorized
}
id, err := uuid.Parse(idStr)
if err != nil {
slog.ErrorContext(ctx, "treasureChest delete", "err", err)
return fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
}
tx, err := s.db.BeginTxx(ctx, nil)
err = core.TransformAndLogDbError(ctx, "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 = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil {
return err
}
if childCount > 0 {
return fmt.Errorf("treasure chest has children: %w", core.ErrBadRequest)
}
transactionsCount := 0
err = tx.GetContext(ctx, &transactionsCount,
`SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND treasure_chest_id = ?`,
user.Id, id)
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil {
return err
}
if transactionsCount > 0 {
return fmt.Errorf("treasure chest has transactions: %w", core.ErrBadRequest)
}
recurringCount := 0
err = tx.GetContext(ctx, &recurringCount, `
SELECT COUNT(*) FROM transaction_recurring WHERE user_id = ? AND treasure_chest_id = ?`,
user.Id, id)
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil {
return err
}
if recurringCount > 0 {
return fmt.Errorf("cannot delete treasure chest with existing recurring transactions: %w", core.ErrBadRequest)
}
r, err := tx.ExecContext(ctx, `DELETE FROM treasure_chest WHERE id = ? AND user_id = ?`, id, user.Id)
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", r, err)
if err != nil {
return err
}
err = tx.Commit()
err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil {
return err
}
return nil
}
func SortTreasureChests(nodes []*treasure_chest_types.TreasureChest) []*treasure_chest_types.TreasureChest {
var (
roots []*treasure_chest_types.TreasureChest
)
children := make(map[uuid.UUID][]*treasure_chest_types.TreasureChest)
result := make([]*treasure_chest_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 *treasure_chest_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 *treasure_chest_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
}

View File

@@ -0,0 +1,193 @@
package treasure_chest
import (
"github.com/google/uuid"
"spend-sparrow/internal/template/svg"
"spend-sparrow/internal/treasure_chest_types"
"spend-sparrow/internal/types"
)
templ TreasureChestComp(treasureChests []*treasure_chest_types.TreasureChest, monthlySums map[uuid.UUID]int64) {
<div class="max-w-6xl mt-10 mx-auto">
<button
hx-get="/treasurechest/new"
hx-target="#treasurechest-items"
hx-swap="afterbegin"
class="ml-auto text-center button button-primary px-2 flex items-center gap-2"
>
@svg.Plus()
New Treasure Chest
</button>
<div id="treasurechest-items" class="my-6 flex flex-col">
for _, treasureChest := range treasureChests {
@TreasureChestItem(treasureChest, monthlySums)
}
</div>
</div>
}
templ EditTreasureChest(treasureChest *treasure_chest_types.TreasureChest, parents []*treasure_chest_types.TreasureChest, transactionsRecurring templ.Component) {
{{
var (
id string
name string
parentId uuid.UUID
cancelUrl string
)
indentation := " mt-10"
if treasureChest == nil {
id = "new"
name = ""
parentId = uuid.Nil
cancelUrl = "/empty"
} else {
id = treasureChest.Id.String()
name = treasureChest.Name
if treasureChest.ParentId != nil {
parentId = *treasureChest.ParentId
indentation = " mt-2 ml-14"
}
cancelUrl = "/treasurechest/" + id
}
}}
<div id={ "treasurechest-" + id } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }>
<form
hx-post={ "/treasurechest/" + id }
hx-target={ "#treasurechest-" + id }
hx-swap="outerHTML"
class="text-xl flex justify-end gap-4 items-center"
>
<div class="grow grid grid-cols-[auto_1fr] items-center gap-4">
<label for="name" class="text-sm text-gray-500">Name</label>
<input
autofocus
name="name"
type="text"
value={ name }
placeholder="Treasure Chest Name"
class="bg-white input max-w-96"
/>
<label for="parent-id" class="text-sm text-gray-500">Parent</label>
<select name="parent-id" class="mr-auto bg-white input">
<option value="" class="text-gray-500">-</option>
for _, parent := range filterNoChildNoSelf(parents, id) {
<option
selected?={ parentId == parent.Id }
value={ parent.Id.String() }
>{ parent.Name }</option>
}
</select>
</div>
<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={ "#treasurechest-" + id }
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
</button>
</form>
if id != "new" {
<div class="m-10 border-b-gray-400 border-b-1"></div>
<div class="flex">
<h3 class="text-sm text-gray-500">Monthly Transactions</h3>
<button
hx-get={ "/transaction-recurring?id=new&treasure-chest-id=" + id }
hx-target="next #transaction-recurring"
hx-swap="outerHTML"
class="button button-primary ml-auto px-2 flex items-center gap-2"
>
@svg.Plus()
<p>New Monthly Transaction</p>
</button>
</div>
@transactionsRecurring
}
</div>
}
templ TreasureChestItem(treasureChest *treasure_chest_types.TreasureChest, monthlySums map[uuid.UUID]int64) {
{{
var indentation string
viewTransactions := ""
if treasureChest.ParentId != nil {
indentation = " mt-2 ml-14"
} else {
indentation = " mt-10"
viewTransactions = "hidden"
}
}}
<div id={ "treasurechest-" + treasureChest.Id.String() } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }>
<div class="text-xl flex justify-end items-center gap-4">
<p class="mr-auto">{ treasureChest.Name }</p>
<p class="mr-20 text-gray-600">
if treasureChest.ParentId != nil {
+ { types.FormatEuros(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm">&nbsp;per month</span>
}
</p>
if treasureChest.ParentId != nil {
if treasureChest.CurrentBalance < 0 {
<p class="mr-20 min-w-20 text-right text-red-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p>
} else {
<p class="mr-20 min-w-20 text-right text-green-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p>
}
}
<a
href={ templ.URL("/transaction?treasure-chest-id=" + treasureChest.Id.String()) }
class={ "button button-neglect px-1 flex items-center gap-2 " + viewTransactions }
title="View transactions"
>
@svg.Eye()
<span>
View
</span>
</a>
<button
hx-get={ "/treasurechest/" + treasureChest.Id.String() + "?edit=true" }
hx-target={ "#treasurechest-" + treasureChest.Id.String() }
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={ "#treasurechest-" + treasureChest.Id.String() }
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Delete()
<span>
Delete
</span>
</button>
</div>
</div>
}
func filterNoChildNoSelf(nodes []*treasure_chest_types.TreasureChest, selfId string) []*treasure_chest_types.TreasureChest {
var result []*treasure_chest_types.TreasureChest
for _, node := range nodes {
if node.ParentId == nil && node.Id.String() != selfId {
result = append(result, node)
}
}
return result
}