feat(treasurechest): #64 implement hirarchical treasure chests
This commit was merged in pull request #65.
This commit is contained in:
@@ -163,6 +163,13 @@ func (db AuthSqlite) DeleteUser(userId uuid.UUID) error {
|
|||||||
return types.ErrInternal
|
return types.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec("DELETE FROM treasure_chest WHERE user_id = ?", userId)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
log.Error("Could not delete user: %v", err)
|
||||||
|
return types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Could not commit transaction: %v", err)
|
log.Error("Could not commit transaction: %v", err)
|
||||||
|
|||||||
151
db/treasure_chest.go
Normal file
151
db/treasure_chest.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
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, current_balance, created_at, created_by)
|
||||||
|
VALUES (?,?,?,?,?,?,?)`, treasureChest.Id, treasureChest.ParentId, userId, treasureChest.Name, 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 = ?,
|
||||||
|
current_balance = ?,
|
||||||
|
updated_at = ?,
|
||||||
|
updated_by = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND user_id = ?`, treasureChest.ParentId, treasureChest.Name, 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, 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, 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, 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
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"spend-sparrow/handler/middleware"
|
"spend-sparrow/handler/middleware"
|
||||||
"spend-sparrow/service"
|
"spend-sparrow/service"
|
||||||
@@ -10,7 +9,6 @@ import (
|
|||||||
"spend-sparrow/utils"
|
"spend-sparrow/utils"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Account interface {
|
type Account interface {
|
||||||
@@ -65,19 +63,13 @@ func (h AccountImpl) handleAccountItemComp() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
idStr := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
if idStr == "new" {
|
if id == "new" {
|
||||||
comp := t.EditAccount(nil)
|
comp := t.EditAccount(nil)
|
||||||
h.r.Render(r, w, comp)
|
h.r.Render(r, w, comp)
|
||||||
return
|
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)
|
account, err := h.s.Get(user, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
handleError(w, r, err)
|
||||||
@@ -106,20 +98,15 @@ func (h AccountImpl) handleUpdateAccount() http.HandlerFunc {
|
|||||||
account *types.Account
|
account *types.Account
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
idStr := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
name := r.FormValue("name")
|
name := r.FormValue("name")
|
||||||
if idStr == "new" {
|
if id == "new" {
|
||||||
account, err = h.s.Add(user, name)
|
account, err = h.s.Add(user, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
handleError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} 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)
|
account, err = h.s.Update(user, id, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
handleError(w, r, err)
|
||||||
@@ -140,13 +127,9 @@ func (h AccountImpl) handleDeleteAccount() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := uuid.Parse(r.PathValue("id"))
|
id := r.PathValue("id")
|
||||||
if err != nil {
|
|
||||||
handleError(w, r, fmt.Errorf("could not parse Id: %w", service.ErrBadRequest))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.s.Delete(user, id)
|
err := h.s.Delete(user, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(w, r, err)
|
handleError(w, r, err)
|
||||||
return
|
return
|
||||||
|
|||||||
145
handler/treasure_chest.go
Normal file
145
handler/treasure_chest.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"spend-sparrow/handler/middleware"
|
||||||
|
"spend-sparrow/service"
|
||||||
|
t "spend-sparrow/template/treasurechest"
|
||||||
|
"spend-sparrow/types"
|
||||||
|
"spend-sparrow/utils"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TreasureChest interface {
|
||||||
|
Handle(router *http.ServeMux)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TreasureChestImpl struct {
|
||||||
|
s service.TreasureChest
|
||||||
|
a service.Auth
|
||||||
|
r *Render
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTreasureChest(s service.TreasureChest, a service.Auth, r *Render) TreasureChest {
|
||||||
|
return TreasureChestImpl{
|
||||||
|
s: s,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := t.TreasureChest(treasureChests)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
treasureChests, err := h.s.GetAll(user)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "new" {
|
||||||
|
comp := t.EditTreasureChest(nil, treasureChests)
|
||||||
|
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, treasureChests)
|
||||||
|
} else {
|
||||||
|
comp = t.TreasureChestItem(treasureChest)
|
||||||
|
}
|
||||||
|
h.r.Render(r, w, comp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
if id == "new" {
|
||||||
|
treasureChest, err = h.s.Add(user, parentId, name)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
treasureChest, err = h.s.Update(user, id, parentId, name)
|
||||||
|
if err != nil {
|
||||||
|
handleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := t.TreasureChestItem(treasureChest)
|
||||||
|
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
14
main.go
@@ -108,21 +108,25 @@ func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
|
|||||||
|
|
||||||
authDb := db.NewAuthSqlite(d)
|
authDb := db.NewAuthSqlite(d)
|
||||||
accountDb := db.NewAccountSqlite(d)
|
accountDb := db.NewAccountSqlite(d)
|
||||||
|
treasureChestDb := db.NewTreasureChestSqlite(d)
|
||||||
|
|
||||||
randomService := service.NewRandomImpl()
|
randomService := service.NewRandom()
|
||||||
clockService := service.NewClockImpl()
|
clockService := service.NewClock()
|
||||||
mailService := service.NewMailImpl(serverSettings)
|
mailService := service.NewMail(serverSettings)
|
||||||
|
|
||||||
authService := service.NewAuthImpl(authDb, randomService, clockService, mailService, serverSettings)
|
authService := service.NewAuth(authDb, randomService, clockService, mailService, serverSettings)
|
||||||
accountService := service.NewAccountImpl(accountDb, randomService, clockService, serverSettings)
|
accountService := service.NewAccount(accountDb, randomService, clockService, serverSettings)
|
||||||
|
treasureChestService := service.NewTreasureChest(treasureChestDb, randomService, clockService, serverSettings)
|
||||||
|
|
||||||
render := handler.NewRender()
|
render := handler.NewRender()
|
||||||
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, authService, render)
|
||||||
|
|
||||||
indexHandler.Handle(router)
|
indexHandler.Handle(router)
|
||||||
accountHandler.Handle(router)
|
accountHandler.Handle(router)
|
||||||
|
treasureChestHandler.Handle(router)
|
||||||
authHandler.Handle(router)
|
authHandler.Handle(router)
|
||||||
|
|
||||||
// Serve static files (CSS, JS and images)
|
// Serve static files (CSS, JS and images)
|
||||||
|
|||||||
55
main_test.go
55
main_test.go
@@ -881,35 +881,39 @@ func TestIntegrationAuth(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
db, basePath, ctx := setupIntegrationTest(t)
|
db, basePath, ctx := setupIntegrationTest(t)
|
||||||
userId := uuid.New()
|
|
||||||
|
|
||||||
pass := service.GetHashPassword("password", []byte("salt"))
|
userId, csrfToken, sessionId := createValidUserSession(t, db, ctx, basePath, "")
|
||||||
_, err := db.Exec(`
|
|
||||||
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
|
|
||||||
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
|
|
||||||
|
|
||||||
sessionId := "session-id"
|
formData := url.Values{
|
||||||
assert.Nil(t, err)
|
"name": {"Name"},
|
||||||
_, err = db.Exec(`
|
"csrf-token": {csrfToken},
|
||||||
INSERT INTO session (session_id, user_id, created_at, expires_at)
|
}
|
||||||
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
|
req, err := http.NewRequestWithContext(ctx, "POST", basePath+"/account/new", strings.NewReader(formData.Encode()))
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/delete-account", nil)
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("Cookie", "id="+sessionId)
|
req.Header.Set("Cookie", "id="+sessionId)
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
resp, err := httpClient.Do(req)
|
resp, err := httpClient.Do(req)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
html, err := html.Parse(resp.Body)
|
formData = url.Values{
|
||||||
|
"name": {"Name"},
|
||||||
|
"csrf-token": {csrfToken},
|
||||||
|
}
|
||||||
|
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/treasurechest/new", strings.NewReader(formData.Encode()))
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
csrfToken := findCsrfToken(html)
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
assert.NotEqual(t, "", csrfToken)
|
req.Header.Set("Cookie", "id="+sessionId)
|
||||||
formData := url.Values{
|
req.Header.Set("HX-Request", "true")
|
||||||
|
resp, err = httpClient.Do(req)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
formData = url.Values{
|
||||||
"password": {"password"},
|
"password": {"password"},
|
||||||
"csrf-token": {csrfToken},
|
"csrf-token": {csrfToken},
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/delete-account", strings.NewReader(formData.Encode()))
|
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/delete-account", strings.NewReader(formData.Encode()))
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -933,6 +937,9 @@ func TestIntegrationAuth(t *testing.T) {
|
|||||||
err = db.QueryRow("SELECT COUNT(*) FROM account WHERE user_id = ?", userId).Scan(&rows)
|
err = db.QueryRow("SELECT COUNT(*) FROM account WHERE user_id = ?", userId).Scan(&rows)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
assert.Equal(t, 0, rows)
|
assert.Equal(t, 0, rows)
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM treasure_chest WHERE user_id = ?", userId).Scan(&rows)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, 0, rows)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1669,7 +1676,7 @@ func TestIntegrationAccount(t *testing.T) {
|
|||||||
|
|
||||||
db, basePath, ctx := setupIntegrationTest(t)
|
db, basePath, ctx := setupIntegrationTest(t)
|
||||||
|
|
||||||
csrfToken, sessionId := createValidUserSession(t, db, ctx, basePath, "")
|
_, csrfToken, sessionId := createValidUserSession(t, db, ctx, basePath, "")
|
||||||
|
|
||||||
// Insert
|
// Insert
|
||||||
expectedName := "My great Account"
|
expectedName := "My great Account"
|
||||||
@@ -1738,8 +1745,8 @@ func TestIntegrationAccount(t *testing.T) {
|
|||||||
|
|
||||||
db, basePath, ctx := setupIntegrationTest(t)
|
db, basePath, ctx := setupIntegrationTest(t)
|
||||||
|
|
||||||
csrfToken1, sessionId1 := createValidUserSession(t, db, ctx, basePath, "1")
|
_, csrfToken1, sessionId1 := createValidUserSession(t, db, ctx, basePath, "1")
|
||||||
_, sessionId2 := createValidUserSession(t, db, ctx, basePath, "2")
|
_, _, sessionId2 := createValidUserSession(t, db, ctx, basePath, "2")
|
||||||
|
|
||||||
expectedName1 := "Account 1"
|
expectedName1 := "Account 1"
|
||||||
|
|
||||||
@@ -1767,7 +1774,7 @@ func TestIntegrationAccount(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
db, basePath, ctx := setupIntegrationTest(t)
|
db, basePath, ctx := setupIntegrationTest(t)
|
||||||
|
|
||||||
csrfToken, sessionId := createValidUserSession(t, db, ctx, basePath, "")
|
_, csrfToken, sessionId := createValidUserSession(t, db, ctx, basePath, "")
|
||||||
|
|
||||||
data := map[string]int{
|
data := map[string]int{
|
||||||
"<": 400,
|
"<": 400,
|
||||||
@@ -1802,7 +1809,7 @@ func TestIntegrationAccount(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func createValidUserSession(t *testing.T, db *sqlx.DB, ctx context.Context, basePath string, add string) (string, string) {
|
func createValidUserSession(t *testing.T, db *sqlx.DB, ctx context.Context, basePath string, add string) (uuid.UUID, string, string) {
|
||||||
userId := uuid.New()
|
userId := uuid.New()
|
||||||
sessionId := "session-id" + add
|
sessionId := "session-id" + add
|
||||||
pass := service.GetHashPassword("password", []byte("salt"))
|
pass := service.GetHashPassword("password", []byte("salt"))
|
||||||
@@ -1823,7 +1830,7 @@ func createValidUserSession(t *testing.T, db *sqlx.DB, ctx context.Context, base
|
|||||||
VALUES (?, ?, ?, ?, datetime(), datetime("now", "+1 day"))`, csrfToken, userId, sessionId, types.TokenTypeCsrf)
|
VALUES (?, ?, ?, ?, datetime(), datetime("now", "+1 day"))`, csrfToken, userId, sessionId, types.TokenTypeCsrf)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
return csrfToken, sessionId
|
return userId, csrfToken, sessionId
|
||||||
}
|
}
|
||||||
|
|
||||||
func createAnonymousSession(t *testing.T, ctx context.Context, basePath string) (string, string) {
|
func createAnonymousSession(t *testing.T, ctx context.Context, basePath string) (string, string) {
|
||||||
|
|||||||
16
migration/003_treasure_chest.up.sql
Normal file
16
migration/003_treasure_chest.up.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
CREATE TABLE treasure_chest (
|
||||||
|
id TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||||
|
parent_id TEXT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
name 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;
|
||||||
|
|
||||||
@@ -27,10 +27,10 @@ var (
|
|||||||
|
|
||||||
type Account interface {
|
type Account interface {
|
||||||
Add(user *types.User, name string) (*types.Account, error)
|
Add(user *types.User, name string) (*types.Account, error)
|
||||||
Update(user *types.User, id uuid.UUID, name string) (*types.Account, error)
|
Update(user *types.User, id string, name string) (*types.Account, error)
|
||||||
Get(user *types.User, id uuid.UUID) (*types.Account, error)
|
Get(user *types.User, id string) (*types.Account, error)
|
||||||
GetAll(user *types.User) ([]*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 {
|
type AccountImpl struct {
|
||||||
@@ -40,11 +40,11 @@ type AccountImpl struct {
|
|||||||
settings *types.Settings
|
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{
|
return AccountImpl{
|
||||||
db: db,
|
db: db,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
random: NewRandomImpl(),
|
random: random,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ func (s AccountImpl) Add(user *types.User, name string) (*types.Account, error)
|
|||||||
return nil, types.ErrInternal
|
return nil, types.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.validateAccount(name)
|
err = validateString(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -95,17 +95,22 @@ func (s AccountImpl) Add(user *types.User, name string) (*types.Account, error)
|
|||||||
return savedAccount, nil
|
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()
|
accountMetric.WithLabelValues("update").Inc()
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
return nil, ErrUnauthorized
|
||||||
}
|
}
|
||||||
err := s.validateAccount(name)
|
err := validateString(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
return nil, fmt.Errorf("account %v not found: %w", id, ErrBadRequest)
|
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
|
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()
|
accountMetric.WithLabelValues("get").Inc()
|
||||||
|
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrUnauthorized
|
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 != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
return nil, fmt.Errorf("account %v not found: %w", id, ErrBadRequest)
|
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
|
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()
|
accountMetric.WithLabelValues("delete").Inc()
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return ErrUnauthorized
|
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 != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
return fmt.Errorf("account %v not found: %w", id, ErrBadRequest)
|
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
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ type AuthImpl struct {
|
|||||||
serverSettings *types.Settings
|
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{
|
return &AuthImpl{
|
||||||
db: db,
|
db: db,
|
||||||
random: random,
|
random: random,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestSignUp(t *testing.T) {
|
|||||||
mockClock := mocks.NewMockClock(t)
|
mockClock := mocks.NewMockClock(t)
|
||||||
mockMail := mocks.NewMockMail(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!")
|
_, err := underTest.SignUp("invalid email address", "SomeStrongPassword123!")
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ func TestSignUp(t *testing.T) {
|
|||||||
mockClock := mocks.NewMockClock(t)
|
mockClock := mocks.NewMockClock(t)
|
||||||
mockMail := mocks.NewMockMail(t)
|
mockMail := mocks.NewMockMail(t)
|
||||||
|
|
||||||
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
|
underTest := NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
|
||||||
|
|
||||||
weakPasswords := []string{
|
weakPasswords := []string{
|
||||||
"123!ab", // too short
|
"123!ab", // too short
|
||||||
@@ -73,7 +73,7 @@ func TestSignUp(t *testing.T) {
|
|||||||
mockClock.EXPECT().Now().Return(createTime)
|
mockClock.EXPECT().Now().Return(createTime)
|
||||||
mockAuthDb.EXPECT().InsertUser(expected).Return(nil)
|
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)
|
actual, err := underTest.SignUp(email, password)
|
||||||
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
@@ -101,7 +101,7 @@ func TestSignUp(t *testing.T) {
|
|||||||
|
|
||||||
mockAuthDb.EXPECT().InsertUser(user).Return(db.ErrAlreadyExists)
|
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)
|
_, err := underTest.SignUp(user.Email, password)
|
||||||
assert.Equal(t, ErrAccountExists, err)
|
assert.Equal(t, ErrAccountExists, err)
|
||||||
@@ -131,7 +131,7 @@ func TestSendVerificationMail(t *testing.T) {
|
|||||||
return strings.Contains(message, token.Token)
|
return strings.Contains(message, token.Token)
|
||||||
})).Return()
|
})).Return()
|
||||||
|
|
||||||
underTest := NewAuthImpl(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
|
underTest := NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &types.Settings{})
|
||||||
|
|
||||||
underTest.SendVerificationMail(userId, email)
|
underTest.SendVerificationMail(userId, email)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type Clock interface {
|
|||||||
|
|
||||||
type ClockImpl struct{}
|
type ClockImpl struct{}
|
||||||
|
|
||||||
func NewClockImpl() Clock {
|
func NewClock() Clock {
|
||||||
return &ClockImpl{}
|
return &ClockImpl{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
service/default.go
Normal file
13
service/default.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ type MailImpl struct {
|
|||||||
server *types.Settings
|
server *types.Settings
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMailImpl(server *types.Settings) MailImpl {
|
func NewMail(server *types.Settings) MailImpl {
|
||||||
return MailImpl{server: server}
|
return MailImpl{server: server}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type Random interface {
|
|||||||
type RandomImpl struct {
|
type RandomImpl struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRandomImpl() *RandomImpl {
|
func NewRandom() *RandomImpl {
|
||||||
return &RandomImpl{}
|
return &RandomImpl{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
239
service/treasure_chest.go
Normal file
239
service/treasure_chest.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"spend-sparrow/db"
|
||||||
|
"spend-sparrow/log"
|
||||||
|
"spend-sparrow/types"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
treasureChestMetric = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "spendsparrow_treasurechest_total",
|
||||||
|
Help: "The total of treasurechest operations",
|
||||||
|
},
|
||||||
|
[]string{"operation"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
type TreasureChest interface {
|
||||||
|
Add(user *types.User, parentId, name string) (*types.TreasureChest, error)
|
||||||
|
Update(user *types.User, id, parentId, name string) (*types.TreasureChest, error)
|
||||||
|
Get(user *types.User, id string) (*types.TreasureChest, error)
|
||||||
|
GetAll(user *types.User) ([]*types.TreasureChest, error)
|
||||||
|
Delete(user *types.User, id string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type TreasureChestImpl struct {
|
||||||
|
db db.TreasureChest
|
||||||
|
clock Clock
|
||||||
|
random Random
|
||||||
|
settings *types.Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTreasureChest(db db.TreasureChest, random Random, clock Clock, settings *types.Settings) TreasureChest {
|
||||||
|
return TreasureChestImpl{
|
||||||
|
db: db,
|
||||||
|
clock: clock,
|
||||||
|
random: random,
|
||||||
|
settings: settings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s TreasureChestImpl) Add(user *types.User, parentId, name string) (*types.TreasureChest, error) {
|
||||||
|
treasureChestMetric.WithLabelValues("add").Inc()
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
newId, err := s.random.UUID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validateString(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parentUuid := uuid.Nil
|
||||||
|
if parentId != "" {
|
||||||
|
parent, err := s.Get(user, parentId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if parent.ParentId != uuid.Nil {
|
||||||
|
return nil, fmt.Errorf("only a depth of 1 allowed: %w", ErrBadRequest)
|
||||||
|
}
|
||||||
|
parentUuid = parent.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
treasureChest := &types.TreasureChest{
|
||||||
|
Id: newId,
|
||||||
|
ParentId: parentUuid,
|
||||||
|
UserId: user.Id,
|
||||||
|
|
||||||
|
Name: name,
|
||||||
|
|
||||||
|
CurrentBalance: 0,
|
||||||
|
|
||||||
|
CreatedAt: s.clock.Now(),
|
||||||
|
CreatedBy: user.Id,
|
||||||
|
UpdatedAt: nil,
|
||||||
|
UpdatedBy: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.db.Insert(user.Id, treasureChest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
savedtreasureChest, err := s.db.Get(user.Id, newId)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("treasureChest %v not found after insert: %v", newId, err)
|
||||||
|
return nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
return savedtreasureChest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string) (*types.TreasureChest, error) {
|
||||||
|
treasureChestMetric.WithLabelValues("update").Inc()
|
||||||
|
if user == nil {
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
}
|
||||||
|
err := validateString(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("treasureChest update: %v", err)
|
||||||
|
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
treasureChest, err := s.db.Get(user.Id, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrNotFound {
|
||||||
|
return nil, fmt.Errorf("treasureChest %v not found: %w", idStr, ErrBadRequest)
|
||||||
|
}
|
||||||
|
return nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
|
||||||
|
parentUuid := uuid.Nil
|
||||||
|
if parentId != "" {
|
||||||
|
parent, err := s.Get(user, parentId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if parent.ParentId != uuid.Nil {
|
||||||
|
return nil, fmt.Errorf("only a depth of 1 allowed: %w", ErrBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
children, err := s.db.GetAllByParentId(user.Id, treasureChest.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(children) > 0 {
|
||||||
|
return nil, fmt.Errorf("only a depth of 1 allowed: %w", ErrBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
parentUuid = parent.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := s.clock.Now()
|
||||||
|
treasureChest.Name = name
|
||||||
|
treasureChest.ParentId = parentUuid
|
||||||
|
treasureChest.UpdatedAt = ×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
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ templ EditAccount(account *types.Account) {
|
|||||||
name="name"
|
name="name"
|
||||||
type="text"
|
type="text"
|
||||||
value={ name }
|
value={ name }
|
||||||
|
placeholder="Account Name"
|
||||||
class="mr-auto bg-white input"
|
class="mr-auto bg-white input"
|
||||||
/>
|
/>
|
||||||
<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">
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path str
|
|||||||
if loggedIn {
|
if loggedIn {
|
||||||
<a class={ layoutLinkClass(path == "/") } href="/">Dashboard</a>
|
<a class={ layoutLinkClass(path == "/") } href="/">Dashboard</a>
|
||||||
<a class={ layoutLinkClass(path == "/transaction") } href="/transaction">Transaction</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>
|
<a class={ layoutLinkClass(path == "/account") } href="/account">Account</a>
|
||||||
}
|
}
|
||||||
<div class="ml-auto">
|
<div class="ml-auto">
|
||||||
|
|||||||
1
template/treasurechest/default.go
Normal file
1
template/treasurechest/default.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package treasurechest
|
||||||
184
template/treasurechest/treasure_chest.templ
Normal file
184
template/treasurechest/treasure_chest.templ
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package treasurechest
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
import "spend-sparrow/template/svg"
|
||||||
|
import "spend-sparrow/types"
|
||||||
|
|
||||||
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
|
templ TreasureChest(treasureChests []*types.TreasureChest) {
|
||||||
|
<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>New Treasure Chest</p>
|
||||||
|
</button>
|
||||||
|
<div id="treasurechest-items" class="my-6 flex flex-col items-center">
|
||||||
|
for _, treasureChest := range sortTree(treasureChests) {
|
||||||
|
@TreasureChestItem(treasureChest)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.TreasureChest) {
|
||||||
|
{{
|
||||||
|
var (
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
parentId uuid.UUID
|
||||||
|
cancelUrl string
|
||||||
|
)
|
||||||
|
|
||||||
|
if treasureChest == nil {
|
||||||
|
id = "new"
|
||||||
|
name = ""
|
||||||
|
parentId = uuid.Nil
|
||||||
|
cancelUrl = "/empty"
|
||||||
|
} else {
|
||||||
|
id = treasureChest.Id.String()
|
||||||
|
name = treasureChest.Name
|
||||||
|
parentId = treasureChest.ParentId
|
||||||
|
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="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>
|
||||||
|
<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) {
|
||||||
|
{{
|
||||||
|
var identation string
|
||||||
|
if treasureChest.ParentId != uuid.Nil {
|
||||||
|
identation = " mt-2 ml-36"
|
||||||
|
} else {
|
||||||
|
identation = " mt-8"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<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">
|
||||||
|
<p class="mr-auto">{ treasureChest.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 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 {
|
||||||
|
|
||||||
|
euros := float64(balance) / 100
|
||||||
|
return fmt.Sprintf("%.2f €", euros)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortTree(nodes []*types.TreasureChest) []*types.TreasureChest {
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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"`
|
|
||||||
|
|
||||||
AccountId uuid.UUID `db:"account_id"`
|
|
||||||
Name string
|
|
||||||
|
|
||||||
CurrentBalance int64 `db:"current_balance"`
|
|
||||||
|
|
||||||
CreatedAt time.Time `db:"created_at"`
|
|
||||||
CreatedBy uuid.UUID `db:"created_by"`
|
|
||||||
UpdatedAt *time.Time `db:"updated_at"`
|
|
||||||
UpdatedBy *uuid.UUID `db:"updated_by"`
|
|
||||||
}
|
|
||||||
27
types/treasure_chest.go
Normal file
27
types/treasure_chest.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The TreasureChest is a fictional 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 {
|
||||||
|
Id uuid.UUID
|
||||||
|
ParentId uuid.UUID `db:"parent_id"`
|
||||||
|
UserId uuid.UUID `db:"user_id"`
|
||||||
|
|
||||||
|
Name string
|
||||||
|
|
||||||
|
CurrentBalance int64 `db:"current_balance"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
CreatedBy uuid.UUID `db:"created_by"`
|
||||||
|
UpdatedAt *time.Time `db:"updated_at"`
|
||||||
|
UpdatedBy *uuid.UUID `db:"updated_by"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user