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

This commit is contained in:
2026-01-06 20:40:35 +01:00
parent 70d6110bc4
commit b13712b0df
10 changed files with 564 additions and 4 deletions

19
arch.gv
View File

@@ -1,11 +1,24 @@
digraph { digraph {
Budget
RecurringCost
SavingGoal
Tag Tag
Transaction Transaction
Account Account
BankConnection BankConnection
Analytics
// Buckets
Budget
RecurringCost
SavingGoal
// Analytics -> {
// Budget
// RecurringCost
// SavingGoal
// Tag
// Transaction
// Account
// } [label="uses"]
BankConnection -> Transaction [label="imports into"] BankConnection -> Transaction [label="imports into"]
BankConnection -> Account [label="references"] BankConnection -> Account [label="references"]

View File

@@ -107,7 +107,7 @@ func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]Budge
} }
func (s ServiceImpl) isBudgetValid(budget Budget) bool { func (s ServiceImpl) isBudgetValid(budget Budget) bool {
err := core.ValidateString(budget.Name, "description") err := core.ValidateString(budget.Name, "name")
if err != nil { if err != nil {
return false return false
} }

View File

@@ -95,6 +95,7 @@ templ navigation(path string) {
<a class={ layoutLinkClass(strings.HasPrefix(path, "/treasurechest")) } href="/treasurechest">Treasure Chest</a> <a class={ layoutLinkClass(strings.HasPrefix(path, "/treasurechest")) } href="/treasurechest">Treasure Chest</a>
<a class={ layoutLinkClass(strings.HasPrefix(path, "/account")) } href="/account">Account</a> <a class={ layoutLinkClass(strings.HasPrefix(path, "/account")) } href="/account">Account</a>
<a class={ layoutLinkClass(strings.HasPrefix(path, "/budget")) } href="/budget">Budget</a> <a class={ layoutLinkClass(strings.HasPrefix(path, "/budget")) } href="/budget">Budget</a>
<a class={ layoutLinkClass(strings.HasPrefix(path, "/tag")) } href="/tag">Tag</a>
</nav> </nav>
} }

View File

@@ -14,6 +14,7 @@ import (
"spend-sparrow/internal/dashboard" "spend-sparrow/internal/dashboard"
"spend-sparrow/internal/handler" "spend-sparrow/internal/handler"
"spend-sparrow/internal/handler/middleware" "spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/tag"
"spend-sparrow/internal/transaction" "spend-sparrow/internal/transaction"
"spend-sparrow/internal/transaction_recurring" "spend-sparrow/internal/transaction_recurring"
"spend-sparrow/internal/treasure_chest" "spend-sparrow/internal/treasure_chest"
@@ -111,6 +112,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
authDb := authentication.NewDbSqlite(d) authDb := authentication.NewDbSqlite(d)
budgetDb := budget.NewDbSqlite(d) budgetDb := budget.NewDbSqlite(d)
tagDb := tag.NewDbSqlite(d)
randomService := core.NewRandom() randomService := core.NewRandom()
clockService := core.NewClock() clockService := core.NewClock()
@@ -123,6 +125,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
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) budgetService := budget.NewService(budgetDb, randomService, clockService)
tagService := tag.NewService(tagDb, randomService, clockService)
render := core.NewRender() render := core.NewRender()
indexHandler := handler.NewIndex(render, clockService) indexHandler := handler.NewIndex(render, clockService)
@@ -133,6 +136,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
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) budgetHandler := budget.NewHandler(budgetService, render)
tagHandler := tag.NewHandler(tagService, render)
go dailyTaskTimer(ctx, transactionService, authService) go dailyTaskTimer(ctx, transactionService, authService)
@@ -144,6 +148,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
transactionHandler.Handle(router) transactionHandler.Handle(router)
transactionRecurringHandler.Handle(router) transactionRecurringHandler.Handle(router)
budgetHandler.Handle(router) budgetHandler.Handle(router)
tagHandler.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/"))))

96
internal/tag/db.go Normal file
View File

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

179
internal/tag/handler.go Normal file
View File

@@ -0,0 +1,179 @@
package tag
import (
"fmt"
"net/http"
"spend-sparrow/internal/core"
"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 /tag", h.handlePage())
r.Handle("GET /tag/new", h.handleNew())
r.Handle("GET /tag/{id}", h.handleEdit())
r.Handle("POST /tag/{id}", h.handlePost())
r.Handle("DELETE /tag/{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
}
tags, err := h.s.GetAll(r.Context(), user)
if err != nil {
h.r.RenderLayout(r, w, core.ErrorComp(err), user)
return
}
comp := page(tags)
h.r.RenderLayout(r, w, comp, user)
}
}
func (h HandlerImpl) handleNew() 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
}
comp := editNew()
h.r.RenderLayout(r, w, comp, user)
}
}
func (h HandlerImpl) handleEdit() 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
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
return
}
tag, err := h.s.Get(r.Context(), user, id)
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
return
}
comp := edit(*tag)
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
}
}
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse value: %w", core.ErrBadRequest))
return
}
input := Tag{
Id: id,
Name: r.FormValue("name"),
}
if idStr == "new" {
_, err = h.s.Add(r.Context(), user, input)
if err != nil {
core.HandleError(w, r, err)
return
}
} else {
_, err = h.s.Update(r.Context(), user, input)
if err != nil {
core.HandleError(w, r, err)
return
}
}
core.DoRedirect(w, r, "/tag")
}
}
func (h HandlerImpl) handleDelete() 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
}
idStr := r.PathValue("id")
id, err := uuid.Parse(idStr)
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
return
}
err = h.s.Delete(r.Context(), user, id)
if err != nil {
core.HandleError(w, r, err)
return
}
core.DoRedirect(w, r, "/tag")
}
}

115
internal/tag/service.go Normal file
View File

@@ -0,0 +1,115 @@
package tag
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, tag Tag) (*Tag, error)
Update(ctx context.Context, user *auth_types.User, tag Tag) (*Tag, error)
Delete(ctx context.Context, user *auth_types.User, tagId uuid.UUID) error
Get(ctx context.Context, user *auth_types.User, tagId uuid.UUID) (*Tag, error)
GetAll(ctx context.Context, user *auth_types.User) ([]Tag, 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, tag Tag) (*Tag, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
isValid := s.isTagValid(tag)
if !isValid {
return nil, core.ErrBadRequest
}
newId, err := s.random.UUID(ctx)
if err != nil {
return nil, core.ErrInternal
}
tag.Id = newId
tag.UserId = user.Id
tag.CreatedBy = user.Id
tag.CreatedAt = s.clock.Now()
return s.db.Insert(ctx, tag)
}
func (s ServiceImpl) Update(ctx context.Context, user *auth_types.User, input Tag) (*Tag, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
tag, err := s.Get(ctx, user, input.Id)
if err != nil {
return nil, err
}
tag.Name = input.Name
if user.Id != tag.UserId {
return nil, core.ErrBadRequest
}
isValid := s.isTagValid(*tag)
if !isValid {
return nil, core.ErrBadRequest
}
tag.UpdatedBy = &user.Id
now := s.clock.Now()
tag.UpdatedAt = &now
return s.db.Update(ctx, *tag)
}
func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, tagId uuid.UUID) error {
if user == nil {
return core.ErrUnauthorized
}
return s.db.Delete(ctx, user.Id, tagId)
}
func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, tagId uuid.UUID) (*Tag, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
return s.db.Get(ctx, user.Id, tagId)
}
func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]Tag, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
return s.db.GetAll(ctx, user.Id)
}
func (s ServiceImpl) isTagValid(tag Tag) bool {
err := core.ValidateString(tag.Name, "name")
if err != nil {
return false
}
return true
}

119
internal/tag/template.templ Normal file
View File

@@ -0,0 +1,119 @@
package tag
import (
"spend-sparrow/internal/core"
"spend-sparrow/internal/template/svg"
)
templ page(tags []Tag) {
@core.Breadcrumb([]string{"Home", "Tag"}, []string{"/", "/tag"})
<div class="flex flex-wrap gap-20 text-xl mt-10 justify-center">
@newItem()
for _,tag:=range(tags ) {
@item(tag)
}
</div>
}
templ editNew() {
<div class="flex flex-col h-full">
@core.Breadcrumb([]string{"Home", "Tag", "New"}, []string{"/", "/tag", "/tag/new"})
<div class="flex justify-center items-center flex-1">
<form
hx-post={ "/tag/new" }
class="grid grid-cols-4 items-center gap-4 max-w-2xl"
>
<label for="timestamp" class="text-sm text-gray-500">Name</label>
<input
autofocus
name="name"
type="text"
class="bg-white input datetime col-span-3"
/>
<div class="flex gap-6 justify-end col-span-4">
<a href="/tag" class="col-start-3 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
</a>
<button type="submit" class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
@svg.Save()
<span>
Save
</span>
</button>
</div>
</form>
</div>
</div>
}
templ edit(tag Tag) {
<div class="flex flex-col h-full">
@core.Breadcrumb([]string{"Home", "Tag", tag.Name}, []string{"/", "/tag", "/tag/" + tag.Id.String()})
<div class="flex justify-center items-center flex-1">
<form
hx-post={ "/tag/" + tag.Id.String() }
class="grid grid-cols-4 items-center gap-4 max-w-2xl"
>
<label for="timestamp" class="text-sm text-gray-500">Name</label>
<input
autofocus
name="name"
type="text"
value={ tag.Name }
class="bg-white input datetime col-span-3"
/>
<div class="flex flex-row-reverse gap-6 justify-end col-span-4">
<button type="submit" class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
@svg.Save()
<span>
Save
</span>
</button>
<a href="/tag" class="col-start-3 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
</a>
<button
hx-delete={ "/tag/" + tag.Id.String() }
hx-confirm={ "Do you really want to delete '" + tag.Name + "'" }
class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-red-50 rounded-lg hover:underline flex items-center gap-2 justify-center"
>
@svg.Delete()
<span>
Delete
</span>
</button>
</div>
</form>
</div>
</div>
}
templ newItem() {
<a
href="/tag/new"
class="p-5 w-64 h-64 flex gap-10 active:bg-gray-200 flex-col justify-center items-center hover:bg-gray-100 transition-all cursor-pointer rounded-lg bg-gray-50 border-1 border-gray-300"
>
New Tag
<div class="w-10">
@svg.Plus()
</div>
</a>
}
templ item(tag Tag) {
<a href={ "/tag/" + tag.Id.String() } class="p-5 w-64 h-64 flex gap-10 active:bg-gray-200 flex-col justify-center items-center hover:bg-gray-100 transition-all cursor-pointer rounded-lg bg-gray-50 border-1 border-gray-300">
<span>
{ tag.Name }
</span>
</a>
}

19
internal/tag/types.go Normal file
View File

@@ -0,0 +1,19 @@
package tag
import (
"time"
"github.com/google/uuid"
)
type Tag struct {
Id uuid.UUID `db:"id"`
UserId uuid.UUID `db:"user_id"`
Name string `db:"name"`
CreatedAt time.Time `db:"created_at"`
CreatedBy uuid.UUID `db:"created_by"`
UpdatedAt *time.Time `db:"updated_at"`
UpdatedBy *uuid.UUID `db:"updated_by"`
}

13
migration/012_tag.up.sql Normal file
View File

@@ -0,0 +1,13 @@
CREATE TABLE "tag" (
id TEXT NOT NULL UNIQUE PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at DATETIME NOT NULL,
created_by TEXT NOT NULL,
updated_at DATETIME,
updated_by TEXT
) WITHOUT ROWID;