add shopping list and tag management
This commit is contained in:
parent
d2560630f4
commit
b7905ddded
19 changed files with 1253 additions and 11 deletions
|
|
@ -18,6 +18,8 @@ type App struct {
|
||||||
EmailService *service.EmailService
|
EmailService *service.EmailService
|
||||||
ProfileService *service.ProfileService
|
ProfileService *service.ProfileService
|
||||||
SpaceService *service.SpaceService
|
SpaceService *service.SpaceService
|
||||||
|
TagService *service.TagService
|
||||||
|
ShoppingListService *service.ShoppingListService
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config) (*App, error) {
|
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)
|
emailClient := service.NewEmailClient(cfg.MailerSMTPHost, cfg.MailerSMTPPort, cfg.MailerIMAPHost, cfg.MailerIMAPPort, cfg.MailerUsername, cfg.MailerPassword)
|
||||||
|
|
||||||
|
// Repositories
|
||||||
userRepository := repository.NewUserRepository(database)
|
userRepository := repository.NewUserRepository(database)
|
||||||
profileRepository := repository.NewProfileRepository(database)
|
profileRepository := repository.NewProfileRepository(database)
|
||||||
tokenRepository := repository.NewTokenRepository(database)
|
tokenRepository := repository.NewTokenRepository(database)
|
||||||
spaceRepository := repository.NewSpaceRepository(database)
|
spaceRepository := repository.NewSpaceRepository(database)
|
||||||
|
tagRepository := repository.NewTagRepository(database)
|
||||||
|
shoppingListRepository := repository.NewShoppingListRepository(database)
|
||||||
|
listItemRepository := repository.NewListItemRepository(database)
|
||||||
|
|
||||||
|
// Services
|
||||||
userService := service.NewUserService(userRepository)
|
userService := service.NewUserService(userRepository)
|
||||||
spaceService := service.NewSpaceService(spaceRepository)
|
spaceService := service.NewSpaceService(spaceRepository)
|
||||||
emailService := service.NewEmailService(
|
emailService := service.NewEmailService(
|
||||||
|
|
@ -59,6 +66,8 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
cfg.IsProduction(),
|
cfg.IsProduction(),
|
||||||
)
|
)
|
||||||
profileService := service.NewProfileService(profileRepository)
|
profileService := service.NewProfileService(profileRepository)
|
||||||
|
tagService := service.NewTagService(tagRepository)
|
||||||
|
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository)
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
|
|
@ -68,6 +77,8 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
EmailService: emailService,
|
EmailService: emailService,
|
||||||
ProfileService: profileService,
|
ProfileService: profileService,
|
||||||
SpaceService: spaceService,
|
SpaceService: spaceService,
|
||||||
|
TagService: tagService,
|
||||||
|
ShoppingListService: shoppingListService,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
48
internal/db/migrations/00006_create_features_tables.sql
Normal file
48
internal/db/migrations/00006_create_features_tables.sql
Normal 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
242
internal/handler/space.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
21
internal/model/shopping_list.go
Normal file
21
internal/model/shopping_list.go
Normal 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
12
internal/model/tag.go
Normal 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"`
|
||||||
|
}
|
||||||
90
internal/repository/list_item.go
Normal file
90
internal/repository/list_item.go
Normal 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
|
||||||
|
}
|
||||||
83
internal/repository/shopping_list.go
Normal file
83
internal/repository/shopping_list.go
Normal 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
|
||||||
|
}
|
||||||
96
internal/repository/tag.go
Normal file
96
internal/repository/tag.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
auth := handler.NewAuthHandler(a.AuthService)
|
auth := handler.NewAuthHandler(a.AuthService)
|
||||||
home := handler.NewHomeHandler()
|
home := handler.NewHomeHandler()
|
||||||
dashboard := handler.NewDashboardHandler()
|
dashboard := handler.NewDashboardHandler()
|
||||||
|
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
|
@ -51,6 +52,48 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
|
|
||||||
mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage))
|
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
|
// 404
|
||||||
mux.HandleFunc("/{path...}", home.NotFoundPage)
|
mux.HandleFunc("/{path...}", home.NotFoundPage)
|
||||||
|
|
||||||
|
|
|
||||||
146
internal/service/shopping_list.go
Normal file
146
internal/service/shopping_list.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -71,6 +71,15 @@ func (s *SpaceService) GetSpacesForUser(userID string) ([]*model.Space, error) {
|
||||||
return spaces, nil
|
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.
|
// IsMember checks if a user is a member of a given space.
|
||||||
func (s *SpaceService) IsMember(userID, spaceID string) (bool, error) {
|
func (s *SpaceService) IsMember(userID, spaceID string) (bool, error) {
|
||||||
isMember, err := s.spaceRepo.IsMember(spaceID, userID)
|
isMember, err := s.spaceRepo.IsMember(spaceID, userID)
|
||||||
|
|
|
||||||
77
internal/service/tag.go
Normal file
77
internal/service/tag.go
Normal 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)
|
||||||
|
}
|
||||||
38
internal/ui/components/shoppinglist/shoppinglist.templ
Normal file
38
internal/ui/components/shoppinglist/shoppinglist.templ
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
23
internal/ui/components/tag/tag.templ
Normal file
23
internal/ui/components/tag/tag.templ
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
125
internal/ui/layouts/space.templ
Normal file
125
internal/ui/layouts/space.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
internal/ui/pages/app_space_dashboard.templ
Normal file
42
internal/ui/pages/app_space_dashboard.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
45
internal/ui/pages/app_space_list_detail.templ
Normal file
45
internal/ui/pages/app_space_list_detail.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/ui/pages/app_space_lists.templ
Normal file
43
internal/ui/pages/app_space_lists.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
48
internal/ui/pages/app_space_tags.templ
Normal file
48
internal/ui/pages/app_space_tags.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue