diff --git a/arch.gv b/arch.gv index 716d522..0add659 100644 --- a/arch.gv +++ b/arch.gv @@ -1,11 +1,24 @@ digraph { - Budget - RecurringCost - SavingGoal Tag Transaction Account BankConnection + Analytics + + // Buckets + Budget + RecurringCost + SavingGoal + + + // Analytics -> { + // Budget + // RecurringCost + // SavingGoal + // Tag + // Transaction + // Account + // } [label="uses"] BankConnection -> Transaction [label="imports into"] BankConnection -> Account [label="references"] diff --git a/internal/budget/service.go b/internal/budget/service.go index 1e6f23a..8e0cbeb 100644 --- a/internal/budget/service.go +++ b/internal/budget/service.go @@ -107,7 +107,7 @@ func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]Budge } func (s ServiceImpl) isBudgetValid(budget Budget) bool { - err := core.ValidateString(budget.Name, "description") + err := core.ValidateString(budget.Name, "name") if err != nil { return false } diff --git a/internal/core/layout.templ b/internal/core/layout.templ index c0687c5..6989668 100644 --- a/internal/core/layout.templ +++ b/internal/core/layout.templ @@ -95,6 +95,7 @@ templ navigation(path string) { Treasure Chest Account Budget + Tag } diff --git a/internal/default.go b/internal/default.go index 61bf5d9..58c7bc8 100644 --- a/internal/default.go +++ b/internal/default.go @@ -14,6 +14,7 @@ import ( "spend-sparrow/internal/dashboard" "spend-sparrow/internal/handler" "spend-sparrow/internal/handler/middleware" + "spend-sparrow/internal/tag" "spend-sparrow/internal/transaction" "spend-sparrow/internal/transaction_recurring" "spend-sparrow/internal/treasure_chest" @@ -111,6 +112,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings * authDb := authentication.NewDbSqlite(d) budgetDb := budget.NewDbSqlite(d) + tagDb := tag.NewDbSqlite(d) randomService := core.NewRandom() clockService := core.NewClock() @@ -123,6 +125,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings * transactionRecurringService := transaction_recurring.NewService(d, randomService, clockService) dashboardService := dashboard.NewService(d) budgetService := budget.NewService(budgetDb, randomService, clockService) + tagService := tag.NewService(tagDb, randomService, clockService) render := core.NewRender() 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) transactionRecurringHandler := transaction_recurring.NewHandler(transactionRecurringService, render) budgetHandler := budget.NewHandler(budgetService, render) + tagHandler := tag.NewHandler(tagService, render) go dailyTaskTimer(ctx, transactionService, authService) @@ -144,6 +148,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings * transactionHandler.Handle(router) transactionRecurringHandler.Handle(router) budgetHandler.Handle(router) + tagHandler.Handle(router) // Serve static files (CSS, JS and images) router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/")))) diff --git a/internal/tag/db.go b/internal/tag/db.go new file mode 100644 index 0000000..4d0bbce --- /dev/null +++ b/internal/tag/db.go @@ -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 +} diff --git a/internal/tag/handler.go b/internal/tag/handler.go new file mode 100644 index 0000000..ccb5507 --- /dev/null +++ b/internal/tag/handler.go @@ -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") + } +} diff --git a/internal/tag/service.go b/internal/tag/service.go new file mode 100644 index 0000000..7b5318b --- /dev/null +++ b/internal/tag/service.go @@ -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 +} diff --git a/internal/tag/template.templ b/internal/tag/template.templ new file mode 100644 index 0000000..b19ac03 --- /dev/null +++ b/internal/tag/template.templ @@ -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"}) +
+ @newItem() + for _,tag:=range(tags ) { + @item(tag) + } +
+} + +templ editNew() { +
+ @core.Breadcrumb([]string{"Home", "Tag", "New"}, []string{"/", "/tag", "/tag/new"}) +
+
+ + +
+ + + @svg.Cancel() + + + Cancel + + + +
+
+
+
+} + +templ edit(tag Tag) { +
+ @core.Breadcrumb([]string{"Home", "Tag", tag.Name}, []string{"/", "/tag", "/tag/" + tag.Id.String()}) +
+
+ + +
+ + + + @svg.Cancel() + + + Cancel + + + +
+
+
+
+} + +templ newItem() { + + New Tag +
+ @svg.Plus() +
+
+} + +templ item(tag Tag) { + + + { tag.Name } + + +} diff --git a/internal/tag/types.go b/internal/tag/types.go new file mode 100644 index 0000000..3ab1e01 --- /dev/null +++ b/internal/tag/types.go @@ -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"` +} diff --git a/migration/012_tag.up.sql b/migration/012_tag.up.sql new file mode 100644 index 0000000..dba9476 --- /dev/null +++ b/migration/012_tag.up.sql @@ -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; +