add shopping list and tag management

This commit is contained in:
juancwu 2026-01-14 18:19:13 +00:00
commit b7905ddded
19 changed files with 1253 additions and 11 deletions

View file

@ -14,10 +14,12 @@ type App struct {
Cfg *config.Config
DB *sqlx.DB
UserService *service.UserService
AuthService *service.AuthService
EmailService *service.EmailService
ProfileService *service.ProfileService
SpaceService *service.SpaceService
AuthService *service.AuthService
EmailService *service.EmailService
ProfileService *service.ProfileService
SpaceService *service.SpaceService
TagService *service.TagService
ShoppingListService *service.ShoppingListService
}
func New(cfg *config.Config) (*App, error) {
@ -33,11 +35,16 @@ func New(cfg *config.Config) (*App, error) {
emailClient := service.NewEmailClient(cfg.MailerSMTPHost, cfg.MailerSMTPPort, cfg.MailerIMAPHost, cfg.MailerIMAPPort, cfg.MailerUsername, cfg.MailerPassword)
// Repositories
userRepository := repository.NewUserRepository(database)
profileRepository := repository.NewProfileRepository(database)
tokenRepository := repository.NewTokenRepository(database)
spaceRepository := repository.NewSpaceRepository(database)
tagRepository := repository.NewTagRepository(database)
shoppingListRepository := repository.NewShoppingListRepository(database)
listItemRepository := repository.NewListItemRepository(database)
// Services
userService := service.NewUserService(userRepository)
spaceService := service.NewSpaceService(spaceRepository)
emailService := service.NewEmailService(
@ -59,15 +66,19 @@ func New(cfg *config.Config) (*App, error) {
cfg.IsProduction(),
)
profileService := service.NewProfileService(profileRepository)
tagService := service.NewTagService(tagRepository)
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository)
return &App{
Cfg: cfg,
DB: database,
UserService: userService,
AuthService: authService,
EmailService: emailService,
ProfileService: profileService,
SpaceService: spaceService,
Cfg: cfg,
DB: database,
UserService: userService,
AuthService: authService,
EmailService: emailService,
ProfileService: profileService,
SpaceService: spaceService,
TagService: tagService,
ShoppingListService: shoppingListService,
}, nil
}

View file

@ -0,0 +1,48 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY NOT NULL,
space_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (space_id, name),
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS shopping_lists (
id TEXT PRIMARY KEY NOT NULL,
space_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS list_items (
id TEXT PRIMARY KEY NOT NULL,
list_id TEXT NOT NULL,
name TEXT NOT NULL,
is_checked BOOLEAN NOT NULL DEFAULT FALSE,
created_by TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (list_id) REFERENCES shopping_lists(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tags_space_id ON tags(space_id);
CREATE INDEX IF NOT EXISTS idx_shopping_lists_space_id ON shopping_lists(space_id);
CREATE INDEX IF NOT EXISTS idx_list_items_list_id ON list_items(list_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_list_items_list_id;
DROP INDEX IF EXISTS idx_shopping_lists_space_id;
DROP INDEX IF EXISTS idx_tags_space_id;
DROP TABLE IF EXISTS list_items;
DROP TABLE IF EXISTS shopping_lists;
DROP TABLE IF EXISTS tags;
-- +goose StatementEnd

242
internal/handler/space.go Normal file
View file

@ -0,0 +1,242 @@
package handler
import (
"log/slog"
"net/http"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tag"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
)
type SpaceHandler struct {
spaceService *service.SpaceService
tagService *service.TagService
listService *service.ShoppingListService
}
func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService) *SpaceHandler {
return &SpaceHandler{
spaceService: ss,
tagService: ts,
listService: sls,
}
}
func (h *SpaceHandler) DashboardPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
slog.Error("failed to get space", "error", err, "space_id", spaceID)
// The RequireSpaceAccess middleware should prevent this, but as a fallback.
http.Error(w, "Space not found.", http.StatusNotFound)
return
}
lists, err := h.listService.GetListsForSpace(spaceID)
if err != nil {
slog.Error("failed to get shopping lists for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
tags, err := h.tagService.GetTagsForSpace(spaceID)
if err != nil {
slog.Error("failed to get tags for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceDashboardPage(space, lists, tags))
}
func (h *SpaceHandler) ListsPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
http.Error(w, "Space not found", http.StatusNotFound)
return
}
lists, err := h.listService.GetListsForSpace(spaceID)
if err != nil {
slog.Error("failed to get lists for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceListsPage(space, lists))
}
func (h *SpaceHandler) CreateList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
err := r.ParseForm()
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
if name == "" {
// handle error - maybe return a toast
http.Error(w, "List name is required", http.StatusBadRequest)
return
}
newList, err := h.listService.CreateList(spaceID, name)
if err != nil {
slog.Error("failed to create list", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, shoppinglist.ListItem(newList))
}
func (h *SpaceHandler) ListPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
listID := r.PathValue("listID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
http.Error(w, "Space not found", http.StatusNotFound)
return
}
list, err := h.listService.GetList(listID)
if err != nil {
slog.Error("failed to get list", "error", err, "list_id", listID)
http.Error(w, "List not found", http.StatusNotFound)
return
}
items, err := h.listService.GetItemsForList(listID)
if err != nil {
slog.Error("failed to get items for list", "error", err, "list_id", listID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceListDetailPage(space, list, items))
}
func (h *SpaceHandler) AddItemToList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
listID := r.PathValue("listID")
user := ctxkeys.User(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
if name == "" {
http.Error(w, "Item name cannot be empty", http.StatusBadRequest)
return
}
newItem, err := h.listService.AddItemToList(listID, name, user.ID)
if err != nil {
slog.Error("failed to add item to list", "error", err, "list_id", listID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, shoppinglist.ItemDetail(spaceID, newItem))
}
func (h *SpaceHandler) ToggleItem(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
itemID := r.PathValue("itemID")
item, err := h.listService.GetItem(itemID)
if err != nil {
slog.Error("failed to get item", "error", err, "item_id", itemID)
http.Error(w, "Item not found", http.StatusNotFound)
return
}
updatedItem, err := h.listService.UpdateItem(itemID, item.Name, !item.IsChecked)
if err != nil {
slog.Error("failed to toggle item", "error", err, "item_id", itemID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, shoppinglist.ItemDetail(spaceID, updatedItem))
}
func (h *SpaceHandler) DeleteItem(w http.ResponseWriter, r *http.Request) {
itemID := r.PathValue("itemID")
err := h.listService.DeleteItem(itemID)
if err != nil {
slog.Error("failed to delete item", "error", err, "item_id", itemID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) TagsPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
http.Error(w, "Space not found", http.StatusNotFound)
return
}
tags, err := h.tagService.GetTagsForSpace(spaceID)
if err != nil {
slog.Error("failed to get tags for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceTagsPage(space, tags))
}
func (h *SpaceHandler) CreateTag(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
color := r.FormValue("color") // color is optional
var colorPtr *string
if color != "" {
colorPtr = &color
}
newTag, err := h.tagService.CreateTag(spaceID, name, colorPtr)
if err != nil {
slog.Error("failed to create tag", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, tag.Tag(newTag))
}
func (h *SpaceHandler) DeleteTag(w http.ResponseWriter, r *http.Request) {
tagID := r.PathValue("tagID")
err := h.tagService.DeleteTag(tagID)
if err != nil {
slog.Error("failed to delete tag", "error", err, "tag_id", tagID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}

View file

@ -0,0 +1,21 @@
package model
import "time"
type ShoppingList struct {
ID string `db:"id"`
SpaceID string `db:"space_id"`
Name string `db:"name"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type ListItem struct {
ID string `db:"id"`
ListID string `db:"list_id"`
Name string `db:"name"`
IsChecked bool `db:"is_checked"`
CreatedBy string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}

12
internal/model/tag.go Normal file
View file

@ -0,0 +1,12 @@
package model
import "time"
type Tag struct {
ID string `db:"id"`
SpaceID string `db:"space_id"`
Name string `db:"name"`
Color *string `db:"color"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}

View file

@ -0,0 +1,90 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrListItemNotFound = errors.New("list item not found")
)
type ListItemRepository interface {
Create(item *model.ListItem) error
GetByID(id string) (*model.ListItem, error)
GetByListID(listID string) ([]*model.ListItem, error)
Update(item *model.ListItem) error
Delete(id string) error
DeleteByListID(listID string) error
}
type listItemRepository struct {
db *sqlx.DB
}
func NewListItemRepository(db *sqlx.DB) ListItemRepository {
return &listItemRepository{db: db}
}
func (r *listItemRepository) Create(item *model.ListItem) error {
query := `INSERT INTO list_items (id, list_id, name, is_checked, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7);`
_, err := r.db.Exec(query, item.ID, item.ListID, item.Name, item.IsChecked, item.CreatedBy, item.CreatedAt, item.UpdatedAt)
return err
}
func (r *listItemRepository) GetByID(id string) (*model.ListItem, error) {
item := &model.ListItem{}
query := `SELECT * FROM list_items WHERE id = $1;`
err := r.db.Get(item, query, id)
if err == sql.ErrNoRows {
return nil, ErrListItemNotFound
}
return item, err
}
func (r *listItemRepository) GetByListID(listID string) ([]*model.ListItem, error) {
var items []*model.ListItem
query := `SELECT * FROM list_items WHERE list_id = $1 ORDER BY created_at ASC;`
err := r.db.Select(&items, query, listID)
if err != nil {
return nil, err
}
return items, nil
}
func (r *listItemRepository) Update(item *model.ListItem) error {
item.UpdatedAt = time.Now()
query := `UPDATE list_items SET name = $1, is_checked = $2, updated_at = $3 WHERE id = $4;`
result, err := r.db.Exec(query, item.Name, item.IsChecked, item.UpdatedAt, item.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrListItemNotFound
}
return err
}
func (r *listItemRepository) Delete(id string) error {
query := `DELETE FROM list_items WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrListItemNotFound
}
return err
}
func (r *listItemRepository) DeleteByListID(listID string) error {
query := `DELETE FROM list_items WHERE list_id = $1;`
_, err := r.db.Exec(query, listID)
return err
}

View file

@ -0,0 +1,83 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrShoppingListNotFound = errors.New("shopping list not found")
)
type ShoppingListRepository interface {
Create(list *model.ShoppingList) error
GetByID(id string) (*model.ShoppingList, error)
GetBySpaceID(spaceID string) ([]*model.ShoppingList, error)
Update(list *model.ShoppingList) error
Delete(id string) error
}
type shoppingListRepository struct {
db *sqlx.DB
}
func NewShoppingListRepository(db *sqlx.DB) ShoppingListRepository {
return &shoppingListRepository{db: db}
}
func (r *shoppingListRepository) Create(list *model.ShoppingList) error {
query := `INSERT INTO shopping_lists (id, space_id, name, created_at, updated_at) VALUES ($1, $2, $3, $4, $5);`
_, err := r.db.Exec(query, list.ID, list.SpaceID, list.Name, list.CreatedAt, list.UpdatedAt)
return err
}
func (r *shoppingListRepository) GetByID(id string) (*model.ShoppingList, error) {
list := &model.ShoppingList{}
query := `SELECT * FROM shopping_lists WHERE id = $1;`
err := r.db.Get(list, query, id)
if err == sql.ErrNoRows {
return nil, ErrShoppingListNotFound
}
return list, err
}
func (r *shoppingListRepository) GetBySpaceID(spaceID string) ([]*model.ShoppingList, error) {
var lists []*model.ShoppingList
query := `SELECT * FROM shopping_lists WHERE space_id = $1 ORDER BY created_at DESC;`
err := r.db.Select(&lists, query, spaceID)
if err != nil {
return nil, err
}
return lists, nil
}
func (r *shoppingListRepository) Update(list *model.ShoppingList) error {
list.UpdatedAt = time.Now()
query := `UPDATE shopping_lists SET name = $1, updated_at = $2 WHERE id = $3;`
result, err := r.db.Exec(query, list.Name, list.UpdatedAt, list.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrShoppingListNotFound
}
return err
}
func (r *shoppingListRepository) Delete(id string) error {
query := `DELETE FROM shopping_lists WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrShoppingListNotFound
}
return err
}

View file

@ -0,0 +1,96 @@
package repository
import (
"database/sql"
"errors"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrTagNotFound = errors.New("tag not found")
ErrDuplicateTagName = errors.New("tag with that name already exists in this space")
)
type TagRepository interface {
Create(tag *model.Tag) error
GetByID(id string) (*model.Tag, error)
GetBySpaceID(spaceID string) ([]*model.Tag, error)
Update(tag *model.Tag) error
Delete(id string) error
}
type tagRepository struct {
db *sqlx.DB
}
func NewTagRepository(db *sqlx.DB) TagRepository {
return &tagRepository{db: db}
}
func (r *tagRepository) Create(tag *model.Tag) error {
query := `INSERT INTO tags (id, space_id, name, color, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6);`
_, err := r.db.Exec(query, tag.ID, tag.SpaceID, tag.Name, tag.Color, tag.CreatedAt, tag.UpdatedAt)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "duplicate key value") {
return ErrDuplicateTagName
}
return err
}
return nil
}
func (r *tagRepository) GetByID(id string) (*model.Tag, error) {
tag := &model.Tag{}
query := `SELECT * FROM tags WHERE id = $1;`
err := r.db.Get(tag, query, id)
if err == sql.ErrNoRows {
return nil, ErrTagNotFound
}
return tag, err
}
func (r *tagRepository) GetBySpaceID(spaceID string) ([]*model.Tag, error) {
var tags []*model.Tag
query := `SELECT * FROM tags WHERE space_id = $1 ORDER BY name ASC;`
err := r.db.Select(&tags, query, spaceID)
if err != nil {
return nil, err
}
return tags, nil
}
func (r *tagRepository) Update(tag *model.Tag) error {
tag.UpdatedAt = time.Now()
query := `UPDATE tags SET name = $1, color = $2, updated_at = $3 WHERE id = $4;`
result, err := r.db.Exec(query, tag.Name, tag.Color, tag.UpdatedAt, tag.ID)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "duplicate key value") {
return ErrDuplicateTagName
}
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrTagNotFound
}
return err
}
func (r *tagRepository) Delete(id string) error {
query := `DELETE FROM tags WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrTagNotFound
}
return err
}

View file

@ -14,6 +14,7 @@ func SetupRoutes(a *app.App) http.Handler {
auth := handler.NewAuthHandler(a.AuthService)
home := handler.NewHomeHandler()
dashboard := handler.NewDashboardHandler()
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService)
mux := http.NewServeMux()
@ -51,6 +52,48 @@ func SetupRoutes(a *app.App) http.Handler {
mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage))
// Space routes
spaceDashboardHandler := middleware.RequireAuth(space.DashboardPage)
spaceDashboardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(spaceDashboardHandler)
mux.Handle("GET /app/spaces/{spaceID}", spaceDashboardWithAccess)
listsPageHandler := middleware.RequireAuth(space.ListsPage)
listsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsPageHandler)
mux.Handle("GET /app/spaces/{spaceID}/lists", listsPageWithAccess)
createListHandler := middleware.RequireAuth(space.CreateList)
createListWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createListHandler)
mux.Handle("POST /app/spaces/{spaceID}/lists", createListWithAccess)
listPageHandler := middleware.RequireAuth(space.ListPage)
listPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listPageHandler)
mux.Handle("GET /app/spaces/{spaceID}/lists/{listID}", listPageWithAccess)
addItemHandler := middleware.RequireAuth(space.AddItemToList)
addItemWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(addItemHandler)
mux.Handle("POST /app/spaces/{spaceID}/lists/{listID}/items", addItemWithAccess)
toggleItemHandler := middleware.RequireAuth(space.ToggleItem)
toggleItemWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(toggleItemHandler)
mux.Handle("PATCH /app/spaces/{spaceID}/lists/{listID}/items/{itemID}", toggleItemWithAccess)
deleteItemHandler := middleware.RequireAuth(space.DeleteItem)
deleteItemWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteItemHandler)
mux.Handle("DELETE /app/spaces/{spaceID}/lists/{listID}/items/{itemID}", deleteItemWithAccess)
// Tag routes
tagsPageHandler := middleware.RequireAuth(space.TagsPage)
tagsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(tagsPageHandler)
mux.Handle("GET /app/spaces/{spaceID}/tags", tagsPageWithAccess)
createTagHandler := middleware.RequireAuth(space.CreateTag)
createTagWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createTagHandler)
mux.Handle("POST /app/spaces/{spaceID}/tags", createTagWithAccess)
deleteTagHandler := middleware.RequireAuth(space.DeleteTag)
deleteTagWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteTagHandler)
mux.Handle("DELETE /app/spaces/{spaceID}/tags/{tagID}", deleteTagWithAccess)
// 404
mux.HandleFunc("/{path...}", home.NotFoundPage)

View file

@ -0,0 +1,146 @@
package service
import (
"fmt"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type ShoppingListService struct {
listRepo repository.ShoppingListRepository
itemRepo repository.ListItemRepository
}
func NewShoppingListService(listRepo repository.ShoppingListRepository, itemRepo repository.ListItemRepository) *ShoppingListService {
return &ShoppingListService{
listRepo: listRepo,
itemRepo: itemRepo,
}
}
// List methods
func (s *ShoppingListService) CreateList(spaceID, name string) (*model.ShoppingList, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("list name cannot be empty")
}
now := time.Now()
list := &model.ShoppingList{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
CreatedAt: now,
UpdatedAt: now,
}
err := s.listRepo.Create(list)
if err != nil {
return nil, err
}
return list, nil
}
func (s *ShoppingListService) GetListsForSpace(spaceID string) ([]*model.ShoppingList, error) {
return s.listRepo.GetBySpaceID(spaceID)
}
func (s *ShoppingListService) GetList(listID string) (*model.ShoppingList, error) {
return s.listRepo.GetByID(listID)
}
func (s *ShoppingListService) UpdateList(listID, name string) (*model.ShoppingList, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("list name cannot be empty")
}
list, err := s.listRepo.GetByID(listID)
if err != nil {
return nil, err
}
list.Name = name
err = s.listRepo.Update(list)
if err != nil {
return nil, err
}
return list, nil
}
func (s *ShoppingListService) DeleteList(listID string) error {
// First delete all items in the list
err := s.itemRepo.DeleteByListID(listID)
if err != nil {
return fmt.Errorf("failed to delete items in list: %w", err)
}
// Then delete the list itself
return s.listRepo.Delete(listID)
}
// Item methods
func (s *ShoppingListService) AddItemToList(listID, name, createdBy string) (*model.ListItem, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("item name cannot be empty")
}
now := time.Now()
item := &model.ListItem{
ID: uuid.NewString(),
ListID: listID,
Name: name,
IsChecked: false,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,
}
err := s.itemRepo.Create(item)
if err != nil {
return nil, err
}
return item, nil
}
func (s *ShoppingListService) GetItem(itemID string) (*model.ListItem, error) {
return s.itemRepo.GetByID(itemID)
}
func (s *ShoppingListService) GetItemsForList(listID string) ([]*model.ListItem, error) {
return s.itemRepo.GetByListID(listID)
}
func (s *ShoppingListService) UpdateItem(itemID, name string, isChecked bool) (*model.ListItem, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("item name cannot be empty")
}
item, err := s.itemRepo.GetByID(itemID)
if err != nil {
return nil, err
}
item.Name = name
item.IsChecked = isChecked
err = s.itemRepo.Update(item)
if err != nil {
return nil, err
}
return item, nil
}
func (s *ShoppingListService) DeleteItem(itemID string) error {
return s.itemRepo.Delete(itemID)
}

View file

@ -71,6 +71,15 @@ func (s *SpaceService) GetSpacesForUser(userID string) ([]*model.Space, error) {
return spaces, nil
}
// GetSpace retrieves a single space by its ID.
func (s *SpaceService) GetSpace(spaceID string) (*model.Space, error) {
space, err := s.spaceRepo.ByID(spaceID)
if err != nil {
return nil, fmt.Errorf("failed to get space: %w", err)
}
return space, nil
}
// IsMember checks if a user is a member of a given space.
func (s *SpaceService) IsMember(userID, spaceID string) (bool, error) {
isMember, err := s.spaceRepo.IsMember(spaceID, userID)

77
internal/service/tag.go Normal file
View file

@ -0,0 +1,77 @@
package service
import (
"fmt"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type TagService struct {
tagRepo repository.TagRepository
}
func NewTagService(tagRepo repository.TagRepository) *TagService {
return &TagService{tagRepo: tagRepo}
}
func (s *TagService) CreateTag(spaceID, name string, color *string) (*model.Tag, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("tag name cannot be empty")
}
now := time.Now()
tag := &model.Tag{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
Color: color,
CreatedAt: now,
UpdatedAt: now,
}
err := s.tagRepo.Create(tag)
if err != nil {
return nil, err
}
return tag, nil
}
func (s *TagService) GetTagsForSpace(spaceID string) ([]*model.Tag, error) {
return s.tagRepo.GetBySpaceID(spaceID)
}
func (s *TagService) GetTagByID(id string) (*model.Tag, error) {
return s.tagRepo.GetByID(id)
}
func (s *TagService) UpdateTag(id, name string, color *string) (*model.Tag, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("tag name cannot be empty")
}
tag, err := s.tagRepo.GetByID(id)
if err != nil {
return nil, err
}
tag.Name = name
tag.Color = color
err = s.tagRepo.Update(tag)
if err != nil {
return nil, err
}
return tag, nil
}
func (s *TagService) DeleteTag(id string) error {
return s.tagRepo.Delete(id)
}

View file

@ -0,0 +1,38 @@
package shoppinglist
import (
"fmt"
"git.juancwu.dev/juancwu/budgit/internal/model"
)
templ ListItem(list *model.ShoppingList) {
<a href={ templ.URL(fmt.Sprintf("/app/spaces/%s/lists/%s", list.SpaceID, list.ID)) } class="block p-4 border rounded-lg hover:bg-muted transition-colors">
<div class="flex justify-between items-center">
<span class="font-medium">{ list.Name }</span>
// TODO: Add item count or other info
</div>
</a>
}
templ ItemDetail(spaceID string, item *model.ListItem) {
<div id={ "item-" + item.ID } class="flex items-center gap-2 p-2 border-b">
<input
type="checkbox"
name="is_checked"
checked?={ item.IsChecked }
hx-patch={ fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID) }
hx-target={ "#item-" + item.ID }
hx-swap="outerHTML"
class="checkbox"
/>
<span class={ templ.KV("line-through text-muted-foreground", item.IsChecked) }>{ item.Name }</span>
<button
hx-delete={ fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID) }
hx-target={ "#item-" + item.ID }
hx-swap="outerHTML"
class="ml-auto btn btn-xs btn-ghost"
>
&times;
</button>
</div>
}

View file

@ -0,0 +1,23 @@
package tag
import "git.juancwu.dev/juancwu/budgit/internal/model"
templ Tag(tag *model.Tag) {
<div
id={ "tag-" + tag.ID }
class="flex items-center gap-2 rounded-full border px-3 py-1 text-sm"
>
if tag.Color != nil {
<span class="size-3 rounded-full" style={ "background-color: " + *tag.Color }></span>
}
<span>{ tag.Name }</span>
<button
hx-delete={ "/app/spaces/" + tag.SpaceID + "/tags/" + tag.ID }
hx-target={ "#tag-" + tag.ID }
hx-swap="outerHTML"
class="ml-auto text-muted-foreground hover:text-destructive"
>
&times;
</button>
</div>
}

View file

@ -0,0 +1,125 @@
package layouts
import (
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/breadcrumb"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/sidebar"
)
templ Space(title string, space *model.Space) {
{{ cfg := ctxkeys.Config(ctx) }}
@Base(SEOProps{
Title: title,
Description: "Space Dashboard",
Path: ctxkeys.URLPath(ctx),
}) {
@sidebar.Layout() {
@sidebar.Sidebar() {
@sidebar.Header() {
@sidebar.Menu() {
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Size: sidebar.MenuButtonSizeLg,
Href: "/app/dashboard",
}) {
@icon.LayoutDashboard()
<div class="flex flex-col">
<span class="text-sm font-bold">{ cfg.AppName }</span>
<span class="text-xs text-muted-foreground">Back to Home</span>
</div>
}
}
}
}
@sidebar.Content() {
@sidebar.Group() {
@sidebar.GroupLabel() {
{ space.Name }
}
@sidebar.Menu() {
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID,
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID,
Tooltip: "Space Dashboard",
}) {
@icon.House(icon.Props{Class: "size-4"})
<span>Overview</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/lists",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/lists",
Tooltip: "Shopping Lists",
}) {
@icon.ShoppingCart(icon.Props{Class: "size-4"})
<span>Shopping Lists</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/tags",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/tags",
Tooltip: "Tags",
}) {
@icon.Tag(icon.Props{Class: "size-4"})
<span>Tags</span>
}
}
}
}
}
@sidebar.Footer() {
// Re-using the same dropdown from app layout
{{ user := ctxkeys.User(ctx) }}
{{ profile := ctxkeys.Profile(ctx) }}
if user != nil && profile != nil {
@AppSidebarDropdown(user, profile)
}
}
}
@sidebar.Inset() {
// Top Navigation Bar
<header class="sticky top-0 z-10 border-b bg-background">
<div class="flex h-14 items-center px-6">
<div class="flex items-center gap-4">
@sidebar.Trigger()
@breadcrumb.Breadcrumb() {
@breadcrumb.List() {
@breadcrumb.Item() {
@breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/dashboard"}) {
Home
}
}
@breadcrumb.Separator()
@breadcrumb.Item() {
@breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/spaces/" + space.ID}) {
{ space.Name }
}
}
@breadcrumb.Separator()
@breadcrumb.Item() {
@breadcrumb.Page() {
{ title }
}
}
}
}
</div>
<div class="ml-auto flex items-center gap-4">
@blocks.ThemeSwitcher()
</div>
</div>
</header>
// App Content
<main class="flex-1 p-6">
{ children... }
</main>
}
}
}
}

View file

@ -0,0 +1,42 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceDashboardPage(space *model.Space, lists []*model.ShoppingList, tags []*model.Tag) {
@layouts.Space("Dashboard", space) {
<div class="space-y-4">
<h1 class="text-2xl font-bold">Welcome to { space.Name }!</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
// Shopping Lists section
<div class="border rounded-lg p-4">
<h2 class="text-lg font-semibold mb-2">Shopping Lists</h2>
if len(lists) > 0 {
<ul>
for _, list := range lists {
<li>{ list.Name }</li>
}
</ul>
} else {
<p class="text-sm text-muted-foreground">No shopping lists yet.</p>
}
</div>
// Tags section
<div class="border rounded-lg p-4">
<h2 class="text-lg font-semibold mb-2">Tags</h2>
if len(tags) > 0 {
<div class="flex flex-wrap gap-2">
for _, tag := range tags {
<span class="bg-secondary text-secondary-foreground rounded-full px-3 py-1 text-sm">{ tag.Name }</span>
}
</div>
} else {
<p class="text-sm text-muted-foreground">No tags yet.</p>
}
</div>
</div>
</div>
}
}

View file

@ -0,0 +1,45 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceListDetailPage(space *model.Space, list *model.ShoppingList, items []*model.ListItem) {
@layouts.Space(list.Name, space) {
<div class="space-y-4">
<h1 class="text-2xl font-bold">{ list.Name }</h1>
<form
hx-post={ "/app/spaces/" + space.ID + "/lists/" + list.ID + "/items" }
hx-target="#items-container"
hx-swap="beforeend"
_="on htmx:afterRequest reset() me"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "New item...",
})
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
Add Item
}
</form>
<div id="items-container" class="border rounded-lg">
if len(items) == 0 {
<p class="text-center text-muted-foreground p-8">This list is empty.</p>
} else {
for _, item := range items {
@shoppinglist.ItemDetail(space.ID, item)
}
}
</div>
</div>
}
}

View file

@ -0,0 +1,43 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceListsPage(space *model.Space, lists []*model.ShoppingList) {
@layouts.Space("Shopping Lists", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Shopping Lists</h1>
</div>
<form
hx-post={ "/app/spaces/" + space.ID + "/lists" }
hx-target="#lists-container"
hx-swap="beforeend"
_="on htmx:afterRequest reset() me"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "New list name...",
})
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
Create
}
</form>
<div id="lists-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, list := range lists {
@shoppinglist.ListItem(list)
}
</div>
</div>
}
}

View file

@ -0,0 +1,48 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tag"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceTagsPage(space *model.Space, tags []*model.Tag) {
@layouts.Space("Tags", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Tags</h1>
</div>
<form
hx-post={ "/app/spaces/" + space.ID + "/tags" }
hx-target="#tags-container"
hx-swap="beforeend"
_="on htmx:afterRequest reset() me"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "New tag name...",
})
@input.Input(input.Props{
Type: "color",
Name: "color",
Class: "w-14",
})
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
Create
}
</form>
<div id="tags-container" class="flex flex-wrap gap-2">
for _, t := range tags {
@tag.Tag(t)
}
</div>
</div>
}
}