budgit/internal/handler/space.go
2026-01-18 23:38:14 +00:00

549 lines
16 KiB
Go

package handler
import (
"log/slog"
"net/http"
"strconv"
"time"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/event"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
"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/components/toast"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
)
type SpaceHandler struct {
spaceService *service.SpaceService
tagService *service.TagService
listService *service.ShoppingListService
expenseService *service.ExpenseService
inviteService *service.InviteService
eventBus *event.Broker
}
func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService, is *service.InviteService, eb *event.Broker) *SpaceHandler {
return &SpaceHandler{
spaceService: ss,
tagService: ts,
listService: sls,
expenseService: es,
inviteService: is,
eventBus: eb,
}
}
func (h *SpaceHandler) StreamEvents(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
// Set headers for SSE
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Subscribe to events
eventChan := h.eventBus.Subscribe(spaceID)
defer h.eventBus.Unsubscribe(spaceID, eventChan)
// Listen for client disconnect
ctx := r.Context()
// Flush immediately to establish connection
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
for {
select {
case event := <-eventChan:
// Write event to stream
if _, err := w.Write([]byte(event.String())); err != nil {
return
}
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
case <-ctx.Done():
return
}
}
}
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)
}
func (h *SpaceHandler) ExpensesPage(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
}
expenses, err := h.expenseService.GetExpensesForSpace(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)
return
}
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance 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
}
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.SpaceExpensesPage(space, expenses, balance, tags, lists))
}
func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
user := ctxkeys.User(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// --- Form Parsing ---
description := r.FormValue("description")
amountStr := r.FormValue("amount")
typeStr := r.FormValue("type")
dateStr := r.FormValue("date")
tagNames := r.Form["tags"] // Contains tag names
// --- Validation & Conversion ---
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 ---
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 == "" {
continue
}
if processedTags[tagName] {
continue
}
if id, exists := existingTagsMap[tagName]; exists {
finalTagIDs = append(finalTagIDs, id)
} else {
// Create new tag
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.CreateExpenseDTO{
SpaceID: spaceID,
UserID: user.ID,
Description: description,
Amount: amountCents,
Type: expenseType,
Date: date,
TagIDs: finalTagIDs,
ItemIDs: []string{}, // TODO: Add item IDs from form
}
newExpense, err := h.expenseService.CreateExpense(dto)
if err != nil {
slog.Error("failed to create expense", "error", err)
http.Error(w, "Failed to create expense.", http.StatusInternalServerError)
return
}
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
}
ui.Render(w, r, pages.ExpenseCreatedResponse(newExpense, balance))
}
func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
user := ctxkeys.User(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
email := r.FormValue("email")
if email == "" {
http.Error(w, "Email is required", http.StatusBadRequest)
return
}
_, err := h.inviteService.CreateInvite(spaceID, user.ID, email)
if err != nil {
slog.Error("failed to create invite", "error", err, "space_id", spaceID)
http.Error(w, "Failed to create invite", http.StatusInternalServerError)
return
}
ui.Render(w, r, toast.Toast(toast.Props{
Title: "Invitation sent",
Description: "An email has been sent to " + email,
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *SpaceHandler) JoinSpace(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
user := ctxkeys.User(r.Context())
if user != nil {
spaceID, err := h.inviteService.AcceptInvite(token, user.ID)
if err != nil {
slog.Error("failed to accept invite", "error", err, "token", token)
http.Error(w, "Failed to join space: "+err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, "/app/spaces/"+spaceID, http.StatusSeeOther)
return
}
// Not logged in: set cookie and redirect to auth
http.SetCookie(w, &http.Cookie{
Name: "pending_invite",
Value: token,
Path: "/",
Expires: time.Now().Add(1 * time.Hour),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/auth?invite=true", http.StatusTemporaryRedirect)
}
func (h *SpaceHandler) GetBalanceCard(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, expense.BalanceCard(spaceID, balance, false))
}
func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
expenses, err := h.expenseService.GetExpensesForSpace(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))
}
func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
listID := r.PathValue("listID")
items, err := h.listService.GetItemsForList(listID)
if err != nil {
slog.Error("failed to get items", "error", err, "list_id", listID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.ShoppingListItems(spaceID, items))
}
func (h *SpaceHandler) GetLists(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
lists, err := h.listService.GetListsForSpace(spaceID)
if err != nil {
slog.Error("failed to get lists", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.ListsContainer(lists))
}