From b7905ddded9c8a567f97431f760336c4e86eeb96 Mon Sep 17 00:00:00 2001 From: juancwu Date: Wed, 14 Jan 2026 18:19:13 +0000 Subject: [PATCH] add shopping list and tag management --- internal/app/app.go | 33 ++- .../00006_create_features_tables.sql | 48 ++++ internal/handler/space.go | 242 ++++++++++++++++++ internal/model/shopping_list.go | 21 ++ internal/model/tag.go | 12 + internal/repository/list_item.go | 90 +++++++ internal/repository/shopping_list.go | 83 ++++++ internal/repository/tag.go | 96 +++++++ internal/routes/routes.go | 43 ++++ internal/service/shopping_list.go | 146 +++++++++++ internal/service/space.go | 9 + internal/service/tag.go | 77 ++++++ .../shoppinglist/shoppinglist.templ | 38 +++ internal/ui/components/tag/tag.templ | 23 ++ internal/ui/layouts/space.templ | 125 +++++++++ internal/ui/pages/app_space_dashboard.templ | 42 +++ internal/ui/pages/app_space_list_detail.templ | 45 ++++ internal/ui/pages/app_space_lists.templ | 43 ++++ internal/ui/pages/app_space_tags.templ | 48 ++++ 19 files changed, 1253 insertions(+), 11 deletions(-) create mode 100644 internal/db/migrations/00006_create_features_tables.sql create mode 100644 internal/handler/space.go create mode 100644 internal/model/shopping_list.go create mode 100644 internal/model/tag.go create mode 100644 internal/repository/list_item.go create mode 100644 internal/repository/shopping_list.go create mode 100644 internal/repository/tag.go create mode 100644 internal/service/shopping_list.go create mode 100644 internal/service/tag.go create mode 100644 internal/ui/components/shoppinglist/shoppinglist.templ create mode 100644 internal/ui/components/tag/tag.templ create mode 100644 internal/ui/layouts/space.templ create mode 100644 internal/ui/pages/app_space_dashboard.templ create mode 100644 internal/ui/pages/app_space_list_detail.templ create mode 100644 internal/ui/pages/app_space_lists.templ create mode 100644 internal/ui/pages/app_space_tags.templ 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) { + +
+ { list.Name } + // TODO: Add item count or other info +
+
+} + +templ ItemDetail(spaceID string, item *model.ListItem) { +
+ + { item.Name } + +
+} diff --git a/internal/ui/components/tag/tag.templ b/internal/ui/components/tag/tag.templ new file mode 100644 index 0000000..01b716c --- /dev/null +++ b/internal/ui/components/tag/tag.templ @@ -0,0 +1,23 @@ +package tag + +import "git.juancwu.dev/juancwu/budgit/internal/model" + +templ Tag(tag *model.Tag) { +
+ if tag.Color != nil { + + } + { tag.Name } + +
+} diff --git a/internal/ui/layouts/space.templ b/internal/ui/layouts/space.templ new file mode 100644 index 0000000..ca888f5 --- /dev/null +++ b/internal/ui/layouts/space.templ @@ -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() +
+ { cfg.AppName } + Back to Home +
+ } + } + } + } + @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"}) + Overview + } + } + @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"}) + Shopping Lists + } + } + @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"}) + Tags + } + } + } + } + } + @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 +
+
+
+ @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 } + } + } + } + } +
+
+ @blocks.ThemeSwitcher() +
+
+
+ // App Content +
+ { children... } +
+ } + } + } +} diff --git a/internal/ui/pages/app_space_dashboard.templ b/internal/ui/pages/app_space_dashboard.templ new file mode 100644 index 0000000..4ecab88 --- /dev/null +++ b/internal/ui/pages/app_space_dashboard.templ @@ -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) { +
+

Welcome to { space.Name }!

+
+ // Shopping Lists section +
+

Shopping Lists

+ if len(lists) > 0 { +
    + for _, list := range lists { +
  • { list.Name }
  • + } +
+ } else { +

No shopping lists yet.

+ } +
+ // Tags section +
+

Tags

+ if len(tags) > 0 { +
+ for _, tag := range tags { + { tag.Name } + } +
+ } else { +

No tags yet.

+ } +
+
+
+ } +} diff --git a/internal/ui/pages/app_space_list_detail.templ b/internal/ui/pages/app_space_list_detail.templ new file mode 100644 index 0000000..3aa4e47 --- /dev/null +++ b/internal/ui/pages/app_space_list_detail.templ @@ -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) { +
+

{ list.Name }

+
+ @csrf.Token() + @input.Input(input.Props{ + Name: "name", + Placeholder: "New item...", + }) + @button.Button(button.Props{ + Type: button.TypeSubmit, + }) { + Add Item + } +
+
+ if len(items) == 0 { +

This list is empty.

+ } else { + for _, item := range items { + @shoppinglist.ItemDetail(space.ID, item) + } + } +
+
+ } +} diff --git a/internal/ui/pages/app_space_lists.templ b/internal/ui/pages/app_space_lists.templ new file mode 100644 index 0000000..d8abd4a --- /dev/null +++ b/internal/ui/pages/app_space_lists.templ @@ -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) { +
+
+

Shopping Lists

+
+
+ @csrf.Token() + @input.Input(input.Props{ + Name: "name", + Placeholder: "New list name...", + }) + @button.Button(button.Props{ + Type: button.TypeSubmit, + }) { + Create + } +
+
+ for _, list := range lists { + @shoppinglist.ListItem(list) + } +
+
+ } +} diff --git a/internal/ui/pages/app_space_tags.templ b/internal/ui/pages/app_space_tags.templ new file mode 100644 index 0000000..e04d934 --- /dev/null +++ b/internal/ui/pages/app_space_tags.templ @@ -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) { +
+
+

Tags

+
+
+ @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 + } +
+
+ for _, t := range tags { + @tag.Tag(t) + } +
+
+ } +}