diff --git a/internal/budget/db.go b/internal/budget/db.go new file mode 100644 index 0000000..dd51dd0 --- /dev/null +++ b/internal/budget/db.go @@ -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 +} diff --git a/internal/budget/handler.go b/internal/budget/handler.go new file mode 100644 index 0000000..7789f06 --- /dev/null +++ b/internal/budget/handler.go @@ -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) + } +} diff --git a/internal/budget/service.go b/internal/budget/service.go new file mode 100644 index 0000000..732ab12 --- /dev/null +++ b/internal/budget/service.go @@ -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 +} diff --git a/internal/budget/template.templ b/internal/budget/template.templ new file mode 100644 index 0000000..c02856a --- /dev/null +++ b/internal/budget/template.templ @@ -0,0 +1,17 @@ +package budget + +templ Page() { +
+} + +templ Items() { +
+} + +templ Edit() { +
+} + +templ Item() { +
+} diff --git a/internal/budget/types.go b/internal/budget/types.go new file mode 100644 index 0000000..f380d1c --- /dev/null +++ b/internal/budget/types.go @@ -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"` +} diff --git a/internal/core/layout.templ b/internal/core/layout.templ index a0f53c5..68085db 100644 --- a/internal/core/layout.templ +++ b/internal/core/layout.templ @@ -91,5 +91,6 @@ templ navigation(path string) { Transaction Treasure Chest Account + Budget } diff --git a/internal/core/validate_string.go b/internal/core/validate_string.go index 5b01196..6dac4a4 100644 --- a/internal/core/validate_string.go +++ b/internal/core/validate_string.go @@ -6,7 +6,7 @@ import ( ) var ( - safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?]+$`) + safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?\(\)]+$`) ) func ValidateString(value string, fieldName string) error { diff --git a/internal/default.go b/internal/default.go index c3049d2..79f9810 100644 --- a/internal/default.go +++ b/internal/default.go @@ -9,6 +9,7 @@ import ( "os/signal" "spend-sparrow/internal/account" "spend-sparrow/internal/authentication" + "spend-sparrow/internal/budget" "spend-sparrow/internal/core" "spend-sparrow/internal/dashboard" "spend-sparrow/internal/handler" @@ -109,6 +110,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings * var router = http.NewServeMux() authDb := authentication.NewDbSqlite(d) + budgetDb := budget.NewDbSqlite(d) randomService := core.NewRandom() clockService := core.NewClock() @@ -120,6 +122,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings * transactionService := transaction.NewService(d, randomService, clockService) transactionRecurringService := transaction_recurring.NewService(d, randomService, clockService) dashboardService := dashboard.NewService(d) + budgetService := budget.NewService(budgetDb, randomService, clockService) render := core.NewRender() 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) transactionHandler := transaction.NewHandler(transactionService, accountService, treasureChestService, render) transactionRecurringHandler := transaction_recurring.NewHandler(transactionRecurringService, render) + budgetHandler := budget.NewHandler(budgetService, render) go dailyTaskTimer(ctx, transactionService, authService) @@ -139,6 +143,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings * authHandler.Handle(router) transactionHandler.Handle(router) transactionRecurringHandler.Handle(router) + budgetHandler.Handle(router) // Serve static files (CSS, JS and images) router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/")))) diff --git a/migration/010_budget.up.sql b/migration/010_budget.up.sql new file mode 100644 index 0000000..fc4a176 --- /dev/null +++ b/migration/010_budget.up.sql @@ -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; +