feat: add budgets
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 1m11s

This commit is contained in:
2026-01-01 19:57:47 +01:00
parent d7dbca8242
commit 2b320986fd
9 changed files with 385 additions and 1 deletions

97
internal/budget/db.go Normal file
View File

@@ -0,0 +1,97 @@
package budget
import (
"context"
"log/slog"
"spend-sparrow/internal/core"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type Db interface {
Insert(ctx context.Context, budget Budget) (*Budget, error)
Update(ctx context.Context, budget Budget) (*Budget, error)
Delete(ctx context.Context, userId uuid.UUID, budgetId uuid.UUID) error
Get(ctx context.Context, userId uuid.UUID, budgetId uuid.UUID) (*Budget, error)
GetAll(ctx context.Context, userId uuid.UUID) ([]Budget, error)
}
type DbSqlite struct {
db *sqlx.DB
}
func NewDbSqlite(db *sqlx.DB) *DbSqlite {
return &DbSqlite{db: db}
}
func (db DbSqlite) Insert(ctx context.Context, budget Budget) (*Budget, error) {
r, err := db.db.ExecContext(ctx, `
INSERT INTO budget (id, user_id, description, value, created_at, created_by, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
budget.Id, budget.UserId, budget.Description, budget.Value, budget.CreatedAt, budget.CreatedBy, budget.UpdatedAt, budget.UpdatedBy,
)
err = core.TransformAndLogDbError(ctx, "budget", r, err)
if err != nil {
return nil, core.ErrInternal
}
return db.Get(ctx, budget.UserId, budget.Id)
}
func (db DbSqlite) Update(ctx context.Context, budget Budget) (*Budget, error) {
_, err := db.db.ExecContext(ctx, `
UPDATE budget
SET description = ?,
value = ?,
updated_at = ?,
updated_by = ?
WHERE user_id = ?
AND id = ?`,
budget.Description, budget.Value, budget.UpdatedAt, budget.UpdatedBy, budget.UserId, budget.Id)
if err != nil {
slog.ErrorContext(ctx, "SQL error UpdateUser", "err", err)
return nil, core.ErrInternal
}
return db.Get(ctx, budget.UserId, budget.Id)
}
func (db DbSqlite) Delete(ctx context.Context, userId uuid.UUID, budgetId uuid.UUID) error {
r, err := db.db.ExecContext(
ctx,
"DELETE FROM budget WHERE user_id = ? AND id = ?",
userId,
budgetId)
err = core.TransformAndLogDbError(ctx, "budget", r, err)
if err != nil {
return err
}
return nil
}
func (db DbSqlite) Get(ctx context.Context, userId uuid.UUID, budgetId uuid.UUID) (*Budget, error) {
var budget Budget
err := db.db.Get(&budget, "SELECT * FROM budget WHERE id = ? AND user_id = ?", budgetId, userId)
if err != nil {
slog.ErrorContext(ctx, "Could not get budget", "err", err)
return nil, core.ErrInternal
}
return &budget, nil
}
func (db DbSqlite) GetAll(ctx context.Context, userId uuid.UUID) ([]Budget, error) {
var budgets []Budget
err := db.db.Select(&budgets, "SELECT * FROM budget WHERE user_id = ?", userId)
if err != nil {
slog.ErrorContext(ctx, "Could not GetAll budget", "err", err)
return nil, core.ErrInternal
}
return budgets, nil
}

119
internal/budget/handler.go Normal file
View File

@@ -0,0 +1,119 @@
package budget
import (
"fmt"
"log/slog"
"math"
"net/http"
"spend-sparrow/internal/core"
"strconv"
"github.com/google/uuid"
)
const (
DECIMALS_MULTIPLIER = 100
)
type Handler interface {
Handle(router *http.ServeMux)
}
type HandlerImpl struct {
s Service
r *core.Render
}
func NewHandler(s Service, r *core.Render) Handler {
return HandlerImpl{
s: s,
r: r,
}
}
func (h HandlerImpl) Handle(r *http.ServeMux) {
r.Handle("GET /budget", h.handlePage())
// r.Handle("GET /budget/{id}", h.handleTransactionItemComp())
r.Handle("POST /budget/{id}", h.handlePost())
// r.Handle("DELETE /budget/{id}", h.handleDelete())
}
func (h HandlerImpl) handlePage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
core.DoRedirect(w, r, "/auth/signin")
return
}
// transactions, err := h.s.GetAll(r.Context(), user)
// if err != nil {
// core.HandleError(w, r, err)
// return
// }
comp := Page()
h.r.RenderLayout(r, w, comp, user)
}
}
func (h HandlerImpl) handlePost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
core.DoRedirect(w, r, "/auth/signin")
return
}
var (
id uuid.UUID
err error
)
idStr := r.PathValue("id")
if idStr != "new" {
id, err = uuid.Parse(idStr)
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
return
}
}
valueF, err := strconv.ParseFloat(r.FormValue("value"), 64)
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse value: %w", core.ErrBadRequest))
return
}
value := int64(math.Round(valueF * DECIMALS_MULTIPLIER))
input := Budget{
Id: id,
Value: value,
Description: r.FormValue("description"),
}
var budget *Budget
if idStr == "new" {
budget, err = h.s.Add(r.Context(), user, input)
if err != nil {
core.HandleError(w, r, err)
return
}
} else {
budget, err = h.s.Update(r.Context(), user, input)
if err != nil {
core.HandleError(w, r, err)
return
}
}
// To disable unused variable
slog.Info("test", "item", budget)
comp := Item()
h.r.Render(r, w, comp)
}
}

111
internal/budget/service.go Normal file
View File

@@ -0,0 +1,111 @@
package budget
import (
"context"
"spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core"
"github.com/google/uuid"
)
type Service interface {
Add(ctx context.Context, user *auth_types.User, budget Budget) (*Budget, error)
Update(ctx context.Context, user *auth_types.User, budget Budget) (*Budget, error)
Delete(ctx context.Context, user *auth_types.User, budgetId uuid.UUID) error
Get(ctx context.Context, user *auth_types.User, budgetId uuid.UUID) (*Budget, error)
GetAll(ctx context.Context, user *auth_types.User) ([]Budget, error)
}
type ServiceImpl struct {
db Db
clock core.Clock
random core.Random
}
func NewService(db 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, budget Budget) (*Budget, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
isValid := s.isBudgetValid(ctx, budget)
if !isValid {
return nil, core.ErrBadRequest
}
newId, err := s.random.UUID(ctx)
if err != nil {
return nil, core.ErrInternal
}
budget.Id = newId
budget.UserId = user.Id
budget.CreatedBy = user.Id
budget.CreatedAt = s.clock.Now()
return s.db.Insert(ctx, budget)
}
func (s ServiceImpl) Update(ctx context.Context, user *auth_types.User, budget Budget) (*Budget, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
if user.Id != budget.UserId {
return nil, core.ErrBadRequest
}
isValid := s.isBudgetValid(ctx, budget)
if !isValid {
return nil, core.ErrBadRequest
}
budget.UpdatedBy = &user.Id
now := s.clock.Now()
budget.UpdatedAt = &now
return s.db.Update(ctx, budget)
}
func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, budgetId uuid.UUID) error {
if user == nil {
return core.ErrUnauthorized
}
return s.db.Delete(ctx, user.Id, budgetId)
}
func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, budgetId uuid.UUID) (*Budget, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
return s.db.Get(ctx, user.Id, budgetId)
}
func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]Budget, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
return s.db.GetAll(ctx, user.Id)
}
func (s ServiceImpl) isBudgetValid(ctx context.Context, budget Budget) bool {
if budget.Description != "" {
err := core.ValidateString(budget.Description, "description")
if err != nil {
return true
}
} else if budget.Value < 0 {
return true
}
return true
}

View File

@@ -0,0 +1,17 @@
package budget
templ Page() {
<div></div>
}
templ Items() {
<div></div>
}
templ Edit() {
<div></div>
}
templ Item() {
<div></div>
}

20
internal/budget/types.go Normal file
View File

@@ -0,0 +1,20 @@
package budget
import (
"time"
"github.com/google/uuid"
)
type Budget struct {
Id uuid.UUID `db:"id"`
UserId uuid.UUID `db:"user_id"`
Description string `db:"description"`
Value int64 `db:"value"`
CreatedAt time.Time `db:"created_at"`
CreatedBy uuid.UUID `db:"created_by"`
UpdatedAt *time.Time `db:"updated_at"`
UpdatedBy *uuid.UUID `db:"updated_by"`
}

View File

@@ -91,5 +91,6 @@ templ navigation(path string) {
<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 == "/treasurechest") } href="/treasurechest">Treasure Chest</a>
<a class={ layoutLinkClass(path == "/account") } href="/account">Account</a> <a class={ layoutLinkClass(path == "/account") } href="/account">Account</a>
<a class={ layoutLinkClass(path == "/budget") } href="/budget">Budget</a>
</nav> </nav>
} }

View File

@@ -6,7 +6,7 @@ import (
) )
var ( var (
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?]+$`) safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?\(\)]+$`)
) )
func ValidateString(value string, fieldName string) error { func ValidateString(value string, fieldName string) error {

View File

@@ -9,6 +9,7 @@ import (
"os/signal" "os/signal"
"spend-sparrow/internal/account" "spend-sparrow/internal/account"
"spend-sparrow/internal/authentication" "spend-sparrow/internal/authentication"
"spend-sparrow/internal/budget"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/dashboard" "spend-sparrow/internal/dashboard"
"spend-sparrow/internal/handler" "spend-sparrow/internal/handler"
@@ -109,6 +110,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
var router = http.NewServeMux() var router = http.NewServeMux()
authDb := authentication.NewDbSqlite(d) authDb := authentication.NewDbSqlite(d)
budgetDb := budget.NewDbSqlite(d)
randomService := core.NewRandom() randomService := core.NewRandom()
clockService := core.NewClock() clockService := core.NewClock()
@@ -120,6 +122,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
transactionService := transaction.NewService(d, randomService, clockService) transactionService := transaction.NewService(d, randomService, clockService)
transactionRecurringService := transaction_recurring.NewService(d, randomService, clockService) transactionRecurringService := transaction_recurring.NewService(d, randomService, clockService)
dashboardService := dashboard.NewService(d) dashboardService := dashboard.NewService(d)
budgetService := budget.NewService(budgetDb, randomService, clockService)
render := core.NewRender() render := core.NewRender()
indexHandler := handler.NewIndex(render, clockService) indexHandler := handler.NewIndex(render, clockService)
@@ -129,6 +132,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
treasureChestHandler := treasure_chest.NewHandler(treasureChestService, transactionRecurringService, render) treasureChestHandler := treasure_chest.NewHandler(treasureChestService, transactionRecurringService, render)
transactionHandler := transaction.NewHandler(transactionService, accountService, treasureChestService, render) transactionHandler := transaction.NewHandler(transactionService, accountService, treasureChestService, render)
transactionRecurringHandler := transaction_recurring.NewHandler(transactionRecurringService, render) transactionRecurringHandler := transaction_recurring.NewHandler(transactionRecurringService, render)
budgetHandler := budget.NewHandler(budgetService, render)
go dailyTaskTimer(ctx, transactionService, authService) go dailyTaskTimer(ctx, transactionService, authService)
@@ -139,6 +143,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
authHandler.Handle(router) authHandler.Handle(router)
transactionHandler.Handle(router) transactionHandler.Handle(router)
transactionRecurringHandler.Handle(router) transactionRecurringHandler.Handle(router)
budgetHandler.Handle(router)
// Serve static files (CSS, JS and images) // Serve static files (CSS, JS and images)
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/")))) router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))

View File

@@ -0,0 +1,14 @@
CREATE TABLE "budget" (
id TEXT NOT NULL UNIQUE PRIMARY KEY,
user_id TEXT NOT NULL,
description TEXT NOT NULL,
value INTEGER NOT NULL,
created_at DATETIME NOT NULL,
created_by TEXT NOT NULL,
updated_at DATETIME,
updated_by TEXT
) WITHOUT ROWID;