diff --git a/internal/app/app.go b/internal/app/app.go
index 4034b81..55f6d2d 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -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
}
diff --git a/internal/db/migrations/00006_create_features_tables.sql b/internal/db/migrations/00006_create_features_tables.sql
new file mode 100644
index 0000000..8092729
--- /dev/null
+++ b/internal/db/migrations/00006_create_features_tables.sql
@@ -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
diff --git a/internal/handler/space.go b/internal/handler/space.go
new file mode 100644
index 0000000..e9ba5ab
--- /dev/null
+++ b/internal/handler/space.go
@@ -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)
+}
+
diff --git a/internal/model/shopping_list.go b/internal/model/shopping_list.go
new file mode 100644
index 0000000..57a9484
--- /dev/null
+++ b/internal/model/shopping_list.go
@@ -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"`
+}
diff --git a/internal/model/tag.go b/internal/model/tag.go
new file mode 100644
index 0000000..0b358fa
--- /dev/null
+++ b/internal/model/tag.go
@@ -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"`
+}
diff --git a/internal/repository/list_item.go b/internal/repository/list_item.go
new file mode 100644
index 0000000..d8b4a93
--- /dev/null
+++ b/internal/repository/list_item.go
@@ -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
+}
diff --git a/internal/repository/shopping_list.go b/internal/repository/shopping_list.go
new file mode 100644
index 0000000..798432c
--- /dev/null
+++ b/internal/repository/shopping_list.go
@@ -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
+}
diff --git a/internal/repository/tag.go b/internal/repository/tag.go
new file mode 100644
index 0000000..947dc01
--- /dev/null
+++ b/internal/repository/tag.go
@@ -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
+}
diff --git a/internal/routes/routes.go b/internal/routes/routes.go
index e85c643..a5a52c9 100644
--- a/internal/routes/routes.go
+++ b/internal/routes/routes.go
@@ -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)
diff --git a/internal/service/shopping_list.go b/internal/service/shopping_list.go
new file mode 100644
index 0000000..c4772c0
--- /dev/null
+++ b/internal/service/shopping_list.go
@@ -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)
+}
diff --git a/internal/service/space.go b/internal/service/space.go
index d0fd8c4..5a3fd59 100644
--- a/internal/service/space.go
+++ b/internal/service/space.go
@@ -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)
diff --git a/internal/service/tag.go b/internal/service/tag.go
new file mode 100644
index 0000000..9485e1a
--- /dev/null
+++ b/internal/service/tag.go
@@ -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)
+}
diff --git a/internal/ui/components/shoppinglist/shoppinglist.templ b/internal/ui/components/shoppinglist/shoppinglist.templ
new file mode 100644
index 0000000..49c1822
--- /dev/null
+++ b/internal/ui/components/shoppinglist/shoppinglist.templ
@@ -0,0 +1,38 @@
+package shoppinglist
+
+import (
+ "fmt"
+ "git.juancwu.dev/juancwu/budgit/internal/model"
+)
+
+templ ListItem(list *model.ShoppingList) {
+
+
No shopping lists yet.
+ } +No tags yet.
+ } +This list is empty.
+ } else { + for _, item := range items { + @shoppinglist.ItemDetail(space.ID, item) + } + } +