diff --git a/.gitignore b/.gitignore index 32b4d3b..45b63b8 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ tmp/ *.db-wal output.css +.session diff --git a/Taskfile.yml b/Taskfile.yml index 1327422..f43599b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,13 +1,14 @@ version: "3" vars: TEMPL_PROXYBIND: '{{.TEMPL_PROXYBIND | default "127.0.0.1"}}' + TEMPL_PROXYPORT: '{{.TEMPL_PROXYPORT | default "7331"}}' TEMPL_PROXY: '{{.TEMPL_PROXY | default "http://127.0.0.1:9000"}}' tasks: # Development Tools templ: desc: Run templ with integrated server and hot reload cmds: - - go tool templ generate --watch --cmd="go run ./cmd/server/main.go" --proxybind="{{.TEMPL_PROXYBIND}}" --proxy="{{.TEMPL_PROXY}}" --open-browser=false + - go tool templ generate --watch --cmd="go run ./cmd/server/main.go" --proxybind="{{.TEMPL_PROXYBIND}}" --proxyport="{{.TEMPL_PROXYPORT}}" --proxy="{{.TEMPL_PROXY}}" --open-browser=false tailwind-clean: desc: Clean tailwind output cmds: diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index e3f29ce..4c2d479 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -3,6 +3,7 @@ package handler import ( "log/slog" "net/http" + "strings" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "git.juancwu.dev/juancwu/budgit/internal/service" @@ -43,3 +44,23 @@ func (h *dashboardHandler) DashboardPage(w http.ResponseWriter, r *http.Request) ui.Render(w, r, pages.Dashboard(spaces, totalBalance)) } + +func (h *dashboardHandler) CreateSpace(w http.ResponseWriter, r *http.Request) { + user := ctxkeys.User(r.Context()) + + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + http.Error(w, "Space name is required", http.StatusBadRequest) + return + } + + space, err := h.spaceService.CreateSpace(name, user.ID) + if err != nil { + slog.Error("failed to create space", "error", err, "user_id", user.ID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Redirect", "/app/spaces/"+space.ID) + w.WriteHeader(http.StatusOK) +} diff --git a/internal/handler/space.go b/internal/handler/space.go index de9857f..9a58f4c 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -35,6 +35,20 @@ func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *serv } } +// getExpenseForSpace fetches an expense and verifies it belongs to the given space. +func (h *SpaceHandler) getExpenseForSpace(w http.ResponseWriter, spaceID, expenseID string) *model.Expense { + exp, err := h.expenseService.GetExpense(expenseID) + if err != nil { + http.Error(w, "Expense not found", http.StatusNotFound) + return nil + } + if exp.SpaceID != spaceID { + http.Error(w, "Not Found", http.StatusNotFound) + return nil + } + return exp +} + // getListForSpace fetches a shopping list and verifies it belongs to the given space. // Returns the list on success, or writes an error response and returns nil. func (h *SpaceHandler) getListForSpace(w http.ResponseWriter, spaceID, listID string) *model.ShoppingList { @@ -368,7 +382,7 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) { return } - expenses, err := h.expenseService.GetExpensesForSpace(spaceID) + expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID) if err != nil { slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID) http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -542,7 +556,146 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { return } - ui.Render(w, r, pages.ExpenseCreatedResponse(newExpense, balance)) + // Build tags for the newly created expense + tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{newExpense.ID}) + newExpenseWithTags := &model.ExpenseWithTags{ + Expense: *newExpense, + Tags: tagsMap[newExpense.ID], + } + + ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, newExpenseWithTags, balance)) +} + +func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + expenseID := r.PathValue("expenseID") + + if h.getExpenseForSpace(w, spaceID, expenseID) == nil { + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + description := r.FormValue("description") + amountStr := r.FormValue("amount") + typeStr := r.FormValue("type") + dateStr := r.FormValue("date") + tagNames := r.Form["tags"] + + if description == "" || amountStr == "" || typeStr == "" || dateStr == "" { + http.Error(w, "All fields are required.", http.StatusBadRequest) + return + } + + amountFloat, err := strconv.ParseFloat(amountStr, 64) + if err != nil { + http.Error(w, "Invalid amount format.", http.StatusBadRequest) + return + } + amountCents := int(amountFloat * 100) + + date, err := time.Parse("2006-01-02", dateStr) + if err != nil { + http.Error(w, "Invalid date format.", http.StatusBadRequest) + return + } + + expenseType := model.ExpenseType(typeStr) + if expenseType != model.ExpenseTypeExpense && expenseType != model.ExpenseTypeTopup { + http.Error(w, "Invalid transaction type.", http.StatusBadRequest) + return + } + + // Tag processing (same as CreateExpense) + existingTags, 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 + } + + existingTagsMap := make(map[string]string) + for _, t := range existingTags { + existingTagsMap[t.Name] = t.ID + } + + var finalTagIDs []string + processedTags := make(map[string]bool) + + for _, rawTagName := range tagNames { + tagName := service.NormalizeTagName(rawTagName) + if tagName == "" || processedTags[tagName] { + continue + } + + if id, exists := existingTagsMap[tagName]; exists { + finalTagIDs = append(finalTagIDs, id) + } else { + newTag, err := h.tagService.CreateTag(spaceID, tagName, nil) + if err != nil { + slog.Error("failed to create new tag from expense form", "error", err, "tag_name", tagName) + continue + } + finalTagIDs = append(finalTagIDs, newTag.ID) + existingTagsMap[tagName] = newTag.ID + } + processedTags[tagName] = true + } + + dto := service.UpdateExpenseDTO{ + ID: expenseID, + SpaceID: spaceID, + Description: description, + Amount: amountCents, + Type: expenseType, + Date: date, + TagIDs: finalTagIDs, + } + + updatedExpense, err := h.expenseService.UpdateExpense(dto) + if err != nil { + slog.Error("failed to update expense", "error", err) + http.Error(w, "Failed to update expense.", http.StatusInternalServerError) + return + } + + tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{updatedExpense.ID}) + expWithTags := &model.ExpenseWithTags{ + Expense: *updatedExpense, + Tags: tagsMap[updatedExpense.ID], + } + + balance, err := h.expenseService.GetBalanceForSpace(spaceID) + if err != nil { + slog.Error("failed to get balance after update", "error", err, "space_id", spaceID) + } + + ui.Render(w, r, pages.ExpenseUpdatedResponse(spaceID, expWithTags, balance)) +} + +func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + expenseID := r.PathValue("expenseID") + + if h.getExpenseForSpace(w, spaceID, expenseID) == nil { + return + } + + if err := h.expenseService.DeleteExpense(expenseID, spaceID); err != nil { + slog.Error("failed to delete expense", "error", err, "expense_id", expenseID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + balance, err := h.expenseService.GetBalanceForSpace(spaceID) + if err != nil { + slog.Error("failed to get balance after delete", "error", err, "space_id", spaceID) + } + + ui.Render(w, r, expense.BalanceCard(spaceID, balance, true)) } func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { @@ -621,14 +774,14 @@ func (h *SpaceHandler) GetBalanceCard(w http.ResponseWriter, r *http.Request) { func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") - expenses, err := h.expenseService.GetExpensesForSpace(spaceID) + expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID) if err != nil { slog.Error("failed to get expenses", "error", err, "space_id", spaceID) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - ui.Render(w, r, pages.ExpensesListContent(expenses)) + ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses)) } func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) { @@ -685,6 +838,155 @@ func (h *SpaceHandler) GetListCardItems(w http.ResponseWriter, r *http.Request) ui.Render(w, r, shoppinglist.ListCardItems(spaceID, listID, items, page, totalPages)) } +func (h *SpaceHandler) SettingsPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + slog.Error("failed to get space", "error", err, "space_id", spaceID) + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + members, err := h.spaceService.GetMembers(spaceID) + if err != nil { + slog.Error("failed to get members", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + isOwner := space.OwnerID == user.ID + + var pendingInvites []*model.SpaceInvitation + if isOwner { + pendingInvites, err = h.inviteService.GetPendingInvites(spaceID) + if err != nil { + slog.Error("failed to get pending invites", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } + + ui.Render(w, r, pages.SpaceSettingsPage(space, members, pendingInvites, isOwner, user.ID)) +} + +func (h *SpaceHandler) UpdateSpaceName(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + name := r.FormValue("name") + if name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + + if err := h.spaceService.UpdateSpaceName(spaceID, name); err != nil { + slog.Error("failed to update space name", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusOK) +} + +func (h *SpaceHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + userID := r.PathValue("userID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if userID == user.ID { + http.Error(w, "Cannot remove yourself", http.StatusBadRequest) + return + } + + if err := h.spaceService.RemoveMember(spaceID, userID); err != nil { + slog.Error("failed to remove member", "error", err, "space_id", spaceID, "user_id", userID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (h *SpaceHandler) CancelInvite(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + token := r.PathValue("token") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := h.inviteService.CancelInvite(token); err != nil { + slog.Error("failed to cancel invite", "error", err, "space_id", spaceID, "token", token) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (h *SpaceHandler) GetPendingInvites(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + pendingInvites, err := h.inviteService.GetPendingInvites(spaceID) + if err != nil { + slog.Error("failed to get pending invites", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.PendingInvitesList(spaceID, pendingInvites)) +} + func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) { lists, err := h.listService.GetListsForSpace(spaceID) if err != nil { diff --git a/internal/model/expense.go b/internal/model/expense.go index 48713e3..fa15f3e 100644 --- a/internal/model/expense.go +++ b/internal/model/expense.go @@ -21,6 +21,11 @@ type Expense struct { UpdatedAt time.Time `db:"updated_at"` } +type ExpenseWithTags struct { + Expense + Tags []*Tag +} + type ExpenseTag struct { ExpenseID string `db:"expense_id"` TagID string `db:"tag_id"` diff --git a/internal/model/space.go b/internal/model/space.go index 93e8c9e..f18856c 100644 --- a/internal/model/space.go +++ b/internal/model/space.go @@ -23,3 +23,12 @@ type SpaceMember struct { Role Role `db:"role"` JoinedAt time.Time `db:"joined_at"` } + +type SpaceMemberWithProfile struct { + SpaceID string `db:"space_id"` + UserID string `db:"user_id"` + Role Role `db:"role"` + JoinedAt time.Time `db:"joined_at"` + Name string `db:"name"` + Email string `db:"email"` +} diff --git a/internal/repository/expense.go b/internal/repository/expense.go index 60d89b9..18588eb 100644 --- a/internal/repository/expense.go +++ b/internal/repository/expense.go @@ -18,6 +18,9 @@ type ExpenseRepository interface { GetByID(id string) (*model.Expense, error) GetBySpaceID(spaceID string) ([]*model.Expense, error) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) + GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) + Update(expense *model.Expense, tagIDs []string) error + Delete(id string) error } type expenseRepository struct { @@ -91,10 +94,10 @@ func (r *expenseRepository) GetBySpaceID(spaceID string) ([]*model.Expense, erro func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) { var summaries []*model.TagExpenseSummary query := ` - SELECT - t.id as tag_id, - t.name as tag_name, - t.color as tag_color, + SELECT + t.id as tag_id, + t.name as tag_name, + t.color as tag_color, SUM(e.amount_cents) as total_amount FROM expenses e JOIN expense_tags et ON e.id = et.expense_id @@ -109,3 +112,81 @@ func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate ti } return summaries, nil } + +func (r *expenseRepository) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) { + if len(expenseIDs) == 0 { + return make(map[string][]*model.Tag), nil + } + + type row struct { + ExpenseID string `db:"expense_id"` + ID string `db:"id"` + SpaceID string `db:"space_id"` + Name string `db:"name"` + Color *string `db:"color"` + } + + query, args, err := sqlx.In(` + SELECT et.expense_id, t.id, t.space_id, t.name, t.color + FROM expense_tags et + JOIN tags t ON et.tag_id = t.id + WHERE et.expense_id IN (?) + ORDER BY t.name; + `, expenseIDs) + if err != nil { + return nil, err + } + query = r.db.Rebind(query) + + var rows []row + if err := r.db.Select(&rows, query, args...); err != nil { + return nil, err + } + + result := make(map[string][]*model.Tag) + for _, rw := range rows { + result[rw.ExpenseID] = append(result[rw.ExpenseID], &model.Tag{ + ID: rw.ID, + SpaceID: rw.SpaceID, + Name: rw.Name, + Color: rw.Color, + }) + } + return result, nil +} + +func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) error { + tx, err := r.db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + + query := `UPDATE expenses SET description = $1, amount_cents = $2, type = $3, date = $4, updated_at = $5 WHERE id = $6;` + _, err = tx.Exec(query, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.UpdatedAt, expense.ID) + if err != nil { + return err + } + + // Replace tags: delete all existing, re-insert + _, err = tx.Exec(`DELETE FROM expense_tags WHERE expense_id = $1;`, expense.ID) + if err != nil { + return err + } + + if len(tagIDs) > 0 { + insertTag := `INSERT INTO expense_tags (expense_id, tag_id) VALUES ($1, $2);` + for _, tagID := range tagIDs { + if _, err := tx.Exec(insertTag, expense.ID, tagID); err != nil { + return err + } + } + } + + return tx.Commit() +} + +func (r *expenseRepository) Delete(id string) error { + _, err := r.db.Exec(`DELETE FROM expenses WHERE id = $1;`, id) + return err +} diff --git a/internal/repository/space.go b/internal/repository/space.go index 0f7db6e..a01f38c 100644 --- a/internal/repository/space.go +++ b/internal/repository/space.go @@ -20,6 +20,8 @@ type SpaceRepository interface { AddMember(spaceID, userID string, role model.Role) error RemoveMember(spaceID, userID string) error IsMember(spaceID, userID string) (bool, error) + GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) + UpdateName(spaceID, name string) error } type spaceRepository struct { @@ -106,3 +108,23 @@ func (r *spaceRepository) IsMember(spaceID, userID string) (bool, error) { } return count > 0, nil } + +func (r *spaceRepository) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) { + var members []*model.SpaceMemberWithProfile + query := ` + SELECT sm.space_id, sm.user_id, sm.role, sm.joined_at, + p.name, u.email + FROM space_members sm + JOIN users u ON sm.user_id = u.id + JOIN profiles p ON sm.user_id = p.user_id + WHERE sm.space_id = $1 + ORDER BY sm.role DESC, sm.joined_at ASC;` + err := r.db.Select(&members, query, spaceID) + return members, err +} + +func (r *spaceRepository) UpdateName(spaceID, name string) error { + query := `UPDATE spaces SET name = $1, updated_at = $2 WHERE id = $3;` + _, err := r.db.Exec(query, name, time.Now(), spaceID) + return err +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 16e047b..24fdb2c 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -55,6 +55,7 @@ func SetupRoutes(a *app.App) http.Handler { mux.HandleFunc("POST /auth/onboarding", authRateLimiter(middleware.RequireAuth(auth.CompleteOnboarding))) mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage)) + mux.HandleFunc("POST /app/spaces", middleware.RequireAuth(dashboard.CreateSpace)) mux.HandleFunc("GET /app/settings", middleware.RequireAuth(settings.SettingsPage)) mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword))) @@ -117,6 +118,14 @@ func SetupRoutes(a *app.App) http.Handler { createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler) mux.Handle("POST /app/spaces/{spaceID}/expenses", createExpenseWithAccess) + updateExpenseHandler := middleware.RequireAuth(space.UpdateExpense) + updateExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(updateExpenseHandler) + mux.Handle("PATCH /app/spaces/{spaceID}/expenses/{expenseID}", updateExpenseWithAccess) + + deleteExpenseHandler := middleware.RequireAuth(space.DeleteExpense) + deleteExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteExpenseHandler) + mux.Handle("DELETE /app/spaces/{spaceID}/expenses/{expenseID}", deleteExpenseWithAccess) + // Component routes (HTMX updates) balanceCardHandler := middleware.RequireAuth(space.GetBalanceCard) balanceCardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(balanceCardHandler) @@ -138,6 +147,27 @@ func SetupRoutes(a *app.App) http.Handler { listsComponentWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsComponentHandler) mux.Handle("GET /app/spaces/{spaceID}/components/lists", listsComponentWithAccess) + // Settings routes + settingsPageHandler := middleware.RequireAuth(space.SettingsPage) + settingsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(settingsPageHandler) + mux.Handle("GET /app/spaces/{spaceID}/settings", settingsPageWithAccess) + + updateSpaceNameHandler := middleware.RequireAuth(space.UpdateSpaceName) + updateSpaceNameWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(updateSpaceNameHandler) + mux.Handle("PATCH /app/spaces/{spaceID}/settings/name", updateSpaceNameWithAccess) + + removeMemberHandler := middleware.RequireAuth(space.RemoveMember) + removeMemberWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(removeMemberHandler) + mux.Handle("DELETE /app/spaces/{spaceID}/members/{userID}", removeMemberWithAccess) + + cancelInviteHandler := middleware.RequireAuth(space.CancelInvite) + cancelInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(cancelInviteHandler) + mux.Handle("DELETE /app/spaces/{spaceID}/invites/{token}", cancelInviteWithAccess) + + getPendingInvitesHandler := middleware.RequireAuth(space.GetPendingInvites) + getPendingInvitesWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(getPendingInvitesHandler) + mux.Handle("GET /app/spaces/{spaceID}/settings/invites", getPendingInvitesWithAccess) + // Invite routes createInviteHandler := middleware.RequireAuth(space.CreateInvite) createInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createInviteHandler) diff --git a/internal/service/expense.go b/internal/service/expense.go index a62a6d8..d016eab 100644 --- a/internal/service/expense.go +++ b/internal/service/expense.go @@ -20,6 +20,16 @@ type CreateExpenseDTO struct { ItemIDs []string } +type UpdateExpenseDTO struct { + ID string + SpaceID string + Description string + Amount int + Type model.ExpenseType + Date time.Time + TagIDs []string +} + type ExpenseService struct { expenseRepo repository.ExpenseRepository } @@ -84,3 +94,83 @@ func (s *ExpenseService) GetBalanceForSpace(spaceID string) (int, error) { func (s *ExpenseService) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) { return s.expenseRepo.GetExpensesByTag(spaceID, fromDate, toDate) } + +func (s *ExpenseService) GetExpensesWithTagsForSpace(spaceID string) ([]*model.ExpenseWithTags, error) { + expenses, err := s.expenseRepo.GetBySpaceID(spaceID) + if err != nil { + return nil, err + } + + ids := make([]string, len(expenses)) + for i, e := range expenses { + ids[i] = e.ID + } + + tagsMap, err := s.expenseRepo.GetTagsByExpenseIDs(ids) + if err != nil { + return nil, err + } + + result := make([]*model.ExpenseWithTags, len(expenses)) + for i, e := range expenses { + result[i] = &model.ExpenseWithTags{ + Expense: *e, + Tags: tagsMap[e.ID], + } + } + return result, nil +} + +func (s *ExpenseService) GetExpense(id string) (*model.Expense, error) { + return s.expenseRepo.GetByID(id) +} + +func (s *ExpenseService) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) { + return s.expenseRepo.GetTagsByExpenseIDs(expenseIDs) +} + +func (s *ExpenseService) UpdateExpense(dto UpdateExpenseDTO) (*model.Expense, error) { + if dto.Description == "" { + return nil, fmt.Errorf("expense description cannot be empty") + } + if dto.Amount <= 0 { + return nil, fmt.Errorf("amount must be positive") + } + + existing, err := s.expenseRepo.GetByID(dto.ID) + if err != nil { + return nil, err + } + + existing.Description = dto.Description + existing.AmountCents = dto.Amount + existing.Type = dto.Type + existing.Date = dto.Date + existing.UpdatedAt = time.Now() + + if err := s.expenseRepo.Update(existing, dto.TagIDs); err != nil { + return nil, err + } + + balance, _ := s.GetBalanceForSpace(dto.SpaceID) + s.eventBus.Publish(dto.SpaceID, "balance_changed", map[string]interface{}{ + "balance": balance, + }) + s.eventBus.Publish(dto.SpaceID, "expenses_updated", nil) + + return existing, nil +} + +func (s *ExpenseService) DeleteExpense(id string, spaceID string) error { + if err := s.expenseRepo.Delete(id); err != nil { + return err + } + + balance, _ := s.GetBalanceForSpace(spaceID) + s.eventBus.Publish(spaceID, "balance_changed", map[string]interface{}{ + "balance": balance, + }) + s.eventBus.Publish(spaceID, "expenses_updated", nil) + + return nil +} diff --git a/internal/service/invite.go b/internal/service/invite.go index d12d040..1907a03 100644 --- a/internal/service/invite.go +++ b/internal/service/invite.go @@ -94,6 +94,19 @@ func (s *InviteService) AcceptInvite(token, userID string) (string, error) { return invite.SpaceID, s.inviteRepo.UpdateStatus(token, model.InvitationStatusAccepted) } +func (s *InviteService) CancelInvite(token string) error { + invite, err := s.inviteRepo.GetByToken(token) + if err != nil { + return err + } + + if invite.Status != model.InvitationStatusPending { + return errors.New("invitation is not pending") + } + + return s.inviteRepo.Delete(token) +} + func (s *InviteService) GetPendingInvites(spaceID string) ([]*model.SpaceInvitation, error) { // Filter for pending only in memory or repo? // Repo returns all. diff --git a/internal/service/space.go b/internal/service/space.go index 5a3fd59..d3990b2 100644 --- a/internal/service/space.go +++ b/internal/service/space.go @@ -88,3 +88,25 @@ func (s *SpaceService) IsMember(userID, spaceID string) (bool, error) { } return isMember, nil } + +// GetMembers returns all members of a space with their profile info. +func (s *SpaceService) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) { + members, err := s.spaceRepo.GetMembers(spaceID) + if err != nil { + return nil, fmt.Errorf("failed to get members: %w", err) + } + return members, nil +} + +// RemoveMember removes a member from a space. +func (s *SpaceService) RemoveMember(spaceID, userID string) error { + return s.spaceRepo.RemoveMember(spaceID, userID) +} + +// UpdateSpaceName updates the name of a space. +func (s *SpaceService) UpdateSpaceName(spaceID, name string) error { + if name == "" { + return fmt.Errorf("space name cannot be empty") + } + return s.spaceRepo.UpdateName(spaceID, name) +} diff --git a/internal/ui/components/expense/expense.templ b/internal/ui/components/expense/expense.templ index ea37dd0..97ea942 100644 --- a/internal/ui/components/expense/expense.templ +++ b/internal/ui/components/expense/expense.templ @@ -232,6 +232,106 @@ templ AddExpenseForm(props AddExpenseFormProps) { } +templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTags) { + {{ editDialogID := "edit-expense-" + exp.ID }} + {{ tagValues := make([]string, len(exp.Tags)) }} + for i, t := range exp.Tags { + {{ tagValues[i] = t.Name }} + } +
+ @csrf.Token() + // Type +
+
+ @radio.Radio(radio.Props{ + ID: "edit-type-expense-" + exp.ID, + Name: "type", + Value: "expense", + Checked: exp.Type == model.ExpenseTypeExpense, + }) +
+ @label.Label(label.Props{For: "edit-type-expense-" + exp.ID}) { + Expense + } +
+
+
+ @radio.Radio(radio.Props{ + ID: "edit-type-topup-" + exp.ID, + Name: "type", + Value: "topup", + Checked: exp.Type == model.ExpenseTypeTopup, + }) +
+ @label.Label(label.Props{For: "edit-type-topup-" + exp.ID}) { + Top-up + } +
+
+
+ // Description +
+ @label.Label(label.Props{For: "edit-description-" + exp.ID}) { + Description + } + @input.Input(input.Props{ + Name: "description", + ID: "edit-description-" + exp.ID, + Value: exp.Description, + Attributes: templ.Attributes{"required": "true"}, + }) +
+ // Amount +
+ @label.Label(label.Props{For: "edit-amount-" + exp.ID}) { + Amount + } + @input.Input(input.Props{ + Name: "amount", + ID: "edit-amount-" + exp.ID, + Type: "number", + Value: fmt.Sprintf("%.2f", float64(exp.AmountCents)/100.0), + Attributes: templ.Attributes{"step": "0.01", "required": "true"}, + }) +
+ // Date +
+ @label.Label(label.Props{For: "edit-date-" + exp.ID}) { + Date + } + @datepicker.DatePicker(datepicker.Props{ + ID: "edit-date-" + exp.ID, + Name: "date", + Value: exp.Date, + Attributes: templ.Attributes{"required": "true"}, + }) +
+ // Tags +
+ @label.Label(label.Props{For: "edit-tags-" + exp.ID}) { + Tags + } + @tagsinput.TagsInput(tagsinput.Props{ + ID: "edit-tags-" + exp.ID, + Name: "tags", + Value: tagValues, + Placeholder: "Add tags (press enter)", + }) +
+
+ @button.Button(button.Props{Type: button.TypeSubmit}) { + Save + } +
+
+} + templ BalanceCard(spaceID string, balance int, oob bool) {
Tags } } + @sidebar.MenuItem() { + @sidebar.MenuButton(sidebar.MenuButtonProps{ + Href: "/app/spaces/" + space.ID + "/settings", + IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/settings", + Tooltip: "Settings", + }) { + @icon.Settings(icon.Props{Class: "size-4"}) + Settings + } + } } } } diff --git a/internal/ui/pages/app_dashboard.templ b/internal/ui/pages/app_dashboard.templ index 8ac0743..8144972 100644 --- a/internal/ui/pages/app_dashboard.templ +++ b/internal/ui/pages/app_dashboard.templ @@ -4,7 +4,13 @@ import ( "fmt" "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/label" ) templ Dashboard(spaces []*model.Space, totalBalance int) { @@ -43,11 +49,48 @@ templ Dashboard(spaces []*model.Space, totalBalance int) { } // Option to create a new space - @card.Card(card.Props{ Class: "h-full border-dashed" }) { - @card.Content(card.ContentProps{ Class: "h-full flex flex-col items-center justify-center py-12" }) { -

Need another space?

- // TODO: Add a button or link to create a new space - Create Space (Coming Soon) + @dialog.Dialog(dialog.Props{ID: "create-space-dialog"}) { + @dialog.Trigger() { + @card.Card(card.Props{ Class: "h-full border-dashed cursor-pointer transition-colors hover:border-primary" }) { + @card.Content(card.ContentProps{ Class: "h-full flex flex-col items-center justify-center py-12" }) { + @icon.Plus(icon.Props{Class: "h-8 w-8 text-muted-foreground mb-2"}) +

Create a new space

+ } + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Create Space + } + @dialog.Description() { + Create a new space to organize expenses and shopping lists. + } + } +
+ @csrf.Token() +
+ @label.Label(label.Props{For: "space-name"}) { + Name + } + @input.Input(input.Props{ + ID: "space-name", + Name: "name", + Type: input.TypeText, + Placeholder: "e.g. Household, Trip, Roommates", + }) +
+ @dialog.Footer() { + @dialog.Close(dialog.CloseProps{For: "create-space-dialog"}) { + @button.Button(button.Props{Variant: button.VariantOutline, Type: button.TypeButton}) { + Cancel + } + } + @button.Button(button.Props{Type: button.TypeSubmit}) { + Create + } + } +
} }
diff --git a/internal/ui/pages/app_space_expenses.templ b/internal/ui/pages/app_space_expenses.templ index 42e76dc..102670e 100644 --- a/internal/ui/pages/app_space_expenses.templ +++ b/internal/ui/pages/app_space_expenses.templ @@ -3,13 +3,15 @@ package pages import ( "fmt" "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge" "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" "git.juancwu.dev/juancwu/budgit/internal/ui/components/expense" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" ) -templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems) { +templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, balance int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems) { @layouts.Space("Expenses", space) {
@@ -30,11 +32,11 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i } } @expense.AddExpenseForm(expense.AddExpenseFormProps{ - Space: space, - Tags: tags, - ListsWithItems: listsWithItems, - DialogID: "add-expense-dialog", - }) + Space: space, + Tags: tags, + ListsWithItems: listsWithItems, + DialogID: "add-expense-dialog", + }) } }
@@ -42,45 +44,113 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i @expense.BalanceCard(space.ID, balance, false) // List of expenses
- @ExpensesListContent(expenses) + @ExpensesListContent(space.ID, expenses)
} } -templ ExpensesListContent(expenses []*model.Expense) { +templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags) {

History

if len(expenses) == 0 {

No expenses recorded yet.

} - for _, expense := range expenses { - @ExpenseListItem(expense) + for _, exp := range expenses { + @ExpenseListItem(spaceID, exp) }
} -templ ExpenseListItem(expense *model.Expense) { -
-
-

{ expense.Description }

-

{ expense.Date.Format("Jan 02, 2006") }

+templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) { +
+
+

{ exp.Description }

+

{ exp.Date.Format("Jan 02, 2006") }

+ if len(exp.Tags) > 0 { +
+ for _, t := range exp.Tags { + @badge.Badge(badge.Props{Variant: badge.VariantSecondary}) { + { t.Name } + } + } +
+ }
-
- if expense.Type == model.ExpenseTypeExpense { +
+ if exp.Type == model.ExpenseTypeExpense {

- - { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) } + - { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }

} else {

- + { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) } + + { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }

} + // Edit button + @dialog.Dialog(dialog.Props{ID: "edit-expense-" + exp.ID}) { + @dialog.Trigger() { + @button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) { + @icon.Pencil(icon.Props{Size: 14}) + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Edit Transaction + } + @dialog.Description() { + Update the details of this transaction. + } + } + @expense.EditExpenseForm(spaceID, exp) + } + } + // Delete button + @dialog.Dialog(dialog.Props{ID: "del-expense-" + exp.ID}) { + @dialog.Trigger() { + @button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) { + @icon.Trash2(icon.Props{Size: 14}) + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Delete Transaction + } + @dialog.Description() { + Are you sure you want to delete "{ exp.Description }"? This action cannot be undone. + } + } + @dialog.Footer() { + @dialog.Close() { + @button.Button(button.Props{Variant: button.VariantOutline}) { + Cancel + } + } + @button.Button(button.Props{ + Variant: button.VariantDestructive, + Attributes: templ.Attributes{ + "hx-delete": fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID), + "hx-target": "#expense-" + exp.ID, + "hx-swap": "outerHTML", + }, + }) { + Delete + } + } + } + }
} -templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) { - @ExpenseListItem(newExpense) +templ ExpenseCreatedResponse(spaceID string, newExpense *model.ExpenseWithTags, balance int) { + @ExpenseListItem(spaceID, newExpense) @expense.BalanceCard(newExpense.SpaceID, balance, true) } + +templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTags, balance int) { + @ExpenseListItem(spaceID, exp) + @expense.BalanceCard(exp.SpaceID, balance, true) +} diff --git a/internal/ui/pages/app_space_settings.templ b/internal/ui/pages/app_space_settings.templ new file mode 100644 index 0000000..a33e19d --- /dev/null +++ b/internal/ui/pages/app_space_settings.templ @@ -0,0 +1,263 @@ +package pages + +import ( + "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" +) + +templ SpaceSettingsPage(space *model.Space, members []*model.SpaceMemberWithProfile, pendingInvites []*model.SpaceInvitation, isOwner bool, currentUserID string) { + @layouts.Space("Settings", space) { +
+ // Space Name Section + @card.Card() { + @card.Header() { + @card.Title() { + Space Name + } + @card.Description() { + if isOwner { + Update the name of this space. + } else { + The name of this space. + } + } + } + @card.Content() { + if isOwner { +
+ @csrf.Token() + @input.Input(input.Props{ + Name: "name", + Value: space.Name, + Attributes: templ.Attributes{ + "autocomplete": "off", + "required": true, + }, + }) + @button.Button(button.Props{ + Type: button.TypeSubmit, + }) { + Save + } +
+ } else { +

{ space.Name }

+ } + } + } + // Members Section + @card.Card() { + @card.Header() { + @card.Title() { +
+ @icon.Users(icon.Props{Class: "size-5"}) + Members +
+ } + @card.Description() { + People who have access to this space. + } + } + @card.Content() { +
+ for _, member := range members { + @MemberRow(space.ID, member, isOwner, currentUserID) + } +
+ } + } + // Invitations Section (owner only) + if isOwner { + @card.Card() { + @card.Header() { + @card.Title() { +
+ @icon.Mail(icon.Props{Class: "size-5"}) + Invitations +
+ } + @card.Description() { + Invite new members and manage pending invitations. + } + } + @card.Content() { +
+
+ @csrf.Token() + @input.Input(input.Props{ + Name: "email", + Placeholder: "Email address...", + Attributes: templ.Attributes{ + "type": "email", + "autocomplete": "off", + "required": true, + }, + }) + @button.Button(button.Props{ + Type: button.TypeSubmit, + }) { + @icon.UserPlus(icon.Props{Class: "size-4"}) + Invite + } +
+
+ if len(pendingInvites) > 0 { +

Pending invitations

+
+ for _, invite := range pendingInvites { + @PendingInviteRow(space.ID, invite) + } +
+ } else { +

No pending invitations.

+ } +
+
+ } + } + } +
+ @dialog.Script() + } +} + +templ MemberRow(spaceID string, member *model.SpaceMemberWithProfile, isOwner bool, currentUserID string) { +
+
+
+ { string([]rune(member.Name)[0]) } +
+
+

{ member.Name }

+

{ member.Email }

+
+
+
+ if member.Role == model.RoleOwner { + @badge.Badge(badge.Props{Variant: badge.VariantDefault}) { + @icon.Crown(icon.Props{Class: "size-3"}) + Owner + } + } else { + @badge.Badge(badge.Props{Variant: badge.VariantSecondary}) { + Member + } + } + if isOwner && member.UserID != currentUserID && member.Role != model.RoleOwner { + {{ dialogID := "remove-member-dialog-" + member.UserID }} + @dialog.Dialog(dialog.Props{ID: dialogID}) { + @dialog.Trigger() { + @button.Button(button.Props{ + Variant: button.VariantGhost, + Size: button.SizeIcon, + Type: button.TypeButton, + }) { + @icon.UserMinus(icon.Props{Class: "size-4 text-destructive"}) + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Remove member + } + @dialog.Description() { + Are you sure you want to remove { member.Name } from this space? They will lose access immediately. + } + } + @dialog.Footer() { + @dialog.Close() { + @button.Button(button.Props{ + Variant: button.VariantOutline, + Type: button.TypeButton, + }) { + Cancel + } + } + @dialog.Close() { + @button.Button(button.Props{ + Variant: button.VariantDestructive, + Type: button.TypeButton, + Attributes: templ.Attributes{ + "hx-delete": "/app/spaces/" + spaceID + "/members/" + member.UserID, + "hx-target": "#member-" + member.UserID, + "hx-swap": "outerHTML", + "hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`, + }, + }) { + Remove + } + } + } + } + } + } +
+
+} + +templ PendingInviteRow(spaceID string, invite *model.SpaceInvitation) { +
+
+
+ @icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"}) +
+
+

{ invite.Email }

+

Sent { invite.CreatedAt.Format("Jan 02, 2006") }

+
+
+
+ @badge.Badge(badge.Props{Variant: badge.VariantOutline}) { + Pending + } + @button.Button(button.Props{ + Variant: button.VariantGhost, + Size: button.SizeIcon, + Type: button.TypeButton, + Attributes: templ.Attributes{ + "hx-delete": "/app/spaces/" + spaceID + "/invites/" + invite.Token, + "hx-target": "#invite-" + invite.Token, + "hx-swap": "outerHTML", + "hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`, + }, + }) { + @icon.X(icon.Props{Class: "size-4 text-destructive"}) + } +
+
+} + +templ PendingInvitesList(spaceID string, pendingInvites []*model.SpaceInvitation) { + if len(pendingInvites) > 0 { +

Pending invitations

+
+ for _, invite := range pendingInvites { + @PendingInviteRow(spaceID, invite) + } +
+ } else { +

No pending invitations.

+ } +}