diff --git a/assets/embed.go b/assets/embed.go index 3849d2a..13a4be9 100644 --- a/assets/embed.go +++ b/assets/embed.go @@ -2,5 +2,5 @@ package assets import "embed" -//go:embed js/* css/* +//go:embed js/* css/* fonts/* var AssetsFS embed.FS diff --git a/assets/fonts/geist/geist-mono-variable.woff2 b/assets/fonts/geist/geist-mono-variable.woff2 new file mode 100644 index 0000000..b96b7d4 Binary files /dev/null and b/assets/fonts/geist/geist-mono-variable.woff2 differ diff --git a/assets/fonts/geist/geist-variable.woff2 b/assets/fonts/geist/geist-variable.woff2 new file mode 100644 index 0000000..d101f19 Binary files /dev/null and b/assets/fonts/geist/geist-variable.woff2 differ diff --git a/go.mod b/go.mod index b461cbd..06e6ba2 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/alexedwards/argon2id v1.0.0 github.com/emersion/go-imap v1.2.1 github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.6 github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 @@ -39,7 +40,6 @@ require ( github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/protobuf v1.5.3 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/internal/app/app.go b/internal/app/app.go index a813014..a46ac01 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,6 +5,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/config" "git.juancwu.dev/juancwu/budgit/internal/db" + "git.juancwu.dev/juancwu/budgit/internal/event" "git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/service" "github.com/jmoiron/sqlx" @@ -13,6 +14,7 @@ import ( type App struct { Cfg *config.Config DB *sqlx.DB + EventBus *event.Broker UserService *service.UserService AuthService *service.AuthService EmailService *service.EmailService @@ -35,6 +37,8 @@ func New(cfg *config.Config) (*App, error) { return nil, fmt.Errorf("failed to run migrations: %w", err) } + eventBus := event.NewBroker() + emailClient := service.NewEmailClient(cfg.MailerSMTPHost, cfg.MailerSMTPPort, cfg.MailerIMAPHost, cfg.MailerIMAPPort, cfg.MailerUsername, cfg.MailerPassword) // Repositories @@ -71,13 +75,14 @@ func New(cfg *config.Config) (*App, error) { ) profileService := service.NewProfileService(profileRepository) tagService := service.NewTagService(tagRepository) - shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository) - expenseService := service.NewExpenseService(expenseRepository) + shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository, eventBus) + expenseService := service.NewExpenseService(expenseRepository, eventBus) inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService) return &App{ Cfg: cfg, DB: database, + EventBus: eventBus, UserService: userService, AuthService: authService, EmailService: emailService, diff --git a/internal/event/bus.go b/internal/event/bus.go new file mode 100644 index 0000000..e503119 --- /dev/null +++ b/internal/event/bus.go @@ -0,0 +1,84 @@ +package event + +import ( + "fmt" + "log/slog" + "sync" +) + +type Event struct { + Type string `json:"type"` + Data interface{} `json:"data"` +} + +type Broker struct { + mu sync.RWMutex + subscribers map[string][]chan Event // map[spaceID][]chan Event +} + +func NewBroker() *Broker { + return &Broker{ + subscribers: make(map[string][]chan Event), + } +} + +func (b *Broker) Subscribe(spaceID string) chan Event { + b.mu.Lock() + defer b.mu.Unlock() + + ch := make(chan Event, 10) // buffer slightly to prevent blocking + b.subscribers[spaceID] = append(b.subscribers[spaceID], ch) + + slog.Info("new subscriber", "space_id", spaceID) + return ch +} + +func (b *Broker) Unsubscribe(spaceID string, ch chan Event) { + b.mu.Lock() + defer b.mu.Unlock() + + subs := b.subscribers[spaceID] + for i, sub := range subs { + if sub == ch { + // Remove from slice + b.subscribers[spaceID] = append(subs[:i], subs[i+1:]...) + close(ch) + slog.Info("subscriber removed", "space_id", spaceID) + break + } + } + + if len(b.subscribers[spaceID]) == 0 { + delete(b.subscribers, spaceID) + } +} + +func (b *Broker) Publish(spaceID string, eventType string, data interface{}) { + b.mu.RLock() + defer b.mu.RUnlock() + + subs, ok := b.subscribers[spaceID] + if !ok { + return + } + + event := Event{ + Type: eventType, + Data: data, + } + + slog.Info("publishing event", "space_id", spaceID, "type", eventType) + + for _, ch := range subs { + select { + case ch <- event: + default: + slog.Warn("subscriber channel full, dropping event", "space_id", spaceID) + } + } +} + +// String format for SSE data +func (e Event) String() string { + return fmt.Sprintf("event: %s\ndata: %v\n\n", e.Type, e.Data) +} diff --git a/internal/handler/space.go b/internal/handler/space.go index 06260b9..be913f9 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -7,11 +7,14 @@ import ( "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" ) @@ -21,15 +24,54 @@ type SpaceHandler struct { 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) *SpaceHandler { +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 + } } } @@ -378,8 +420,14 @@ func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { return } - // TODO: Return a nice UI response (toast or list update) - w.Write([]byte("Invitation sent!")) + 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) { @@ -409,3 +457,56 @@ func (h *SpaceHandler) JoinSpace(w http.ResponseWriter, r *http.Request) { }) 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)) +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index a1e18d3..00a3cb2 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -14,7 +14,7 @@ func SetupRoutes(a *app.App) http.Handler { auth := handler.NewAuthHandler(a.AuthService, a.InviteService) home := handler.NewHomeHandler() dashboard := handler.NewDashboardHandler() - space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService) + space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.EventBus) mux := http.NewServeMux() @@ -57,6 +57,11 @@ func SetupRoutes(a *app.App) http.Handler { spaceDashboardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(spaceDashboardHandler) mux.Handle("GET /app/spaces/{spaceID}", spaceDashboardWithAccess) + // SSE + streamHandler := middleware.RequireAuth(space.StreamEvents) + streamWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(streamHandler) + mux.Handle("GET /app/spaces/{spaceID}/stream", streamWithAccess) + listsPageHandler := middleware.RequireAuth(space.ListsPage) listsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsPageHandler) mux.Handle("GET /app/spaces/{spaceID}/lists", listsPageWithAccess) @@ -103,6 +108,23 @@ func SetupRoutes(a *app.App) http.Handler { createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler) mux.Handle("POST /app/spaces/{spaceID}/expenses", createExpenseWithAccess) + // Component routes (HTMX updates) + balanceCardHandler := middleware.RequireAuth(space.GetBalanceCard) + balanceCardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(balanceCardHandler) + mux.Handle("GET /app/spaces/{spaceID}/components/balance", balanceCardWithAccess) + + expensesListHandler := middleware.RequireAuth(space.GetExpensesList) + expensesListWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(expensesListHandler) + mux.Handle("GET /app/spaces/{spaceID}/components/expenses", expensesListWithAccess) + + shoppingListItemsHandler := middleware.RequireAuth(space.GetShoppingListItems) + shoppingListItemsWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(shoppingListItemsHandler) + mux.Handle("GET /app/spaces/{spaceID}/lists/{listID}/items", shoppingListItemsWithAccess) + + listsComponentHandler := middleware.RequireAuth(space.GetLists) + listsComponentWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsComponentHandler) + mux.Handle("GET /app/spaces/{spaceID}/components/lists", listsComponentWithAccess) + // 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 7b9adab..96bf545 100644 --- a/internal/service/expense.go +++ b/internal/service/expense.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "git.juancwu.dev/juancwu/budgit/internal/event" "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" "github.com/google/uuid" @@ -22,10 +23,14 @@ type CreateExpenseDTO struct { type ExpenseService struct { expenseRepo repository.ExpenseRepository + eventBus *event.Broker } -func NewExpenseService(expenseRepo repository.ExpenseRepository) *ExpenseService { - return &ExpenseService{expenseRepo: expenseRepo} +func NewExpenseService(expenseRepo repository.ExpenseRepository, eventBus *event.Broker) *ExpenseService { + return &ExpenseService{ + expenseRepo: expenseRepo, + eventBus: eventBus, + } } func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, error) { @@ -54,6 +59,14 @@ func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, er return nil, err } + // Calculate new balance to broadcast + 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 expense, nil } diff --git a/internal/service/shopping_list.go b/internal/service/shopping_list.go index c4772c0..d694a53 100644 --- a/internal/service/shopping_list.go +++ b/internal/service/shopping_list.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "git.juancwu.dev/juancwu/budgit/internal/event" "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" "github.com/google/uuid" @@ -13,12 +14,14 @@ import ( type ShoppingListService struct { listRepo repository.ShoppingListRepository itemRepo repository.ListItemRepository + eventBus *event.Broker } -func NewShoppingListService(listRepo repository.ShoppingListRepository, itemRepo repository.ListItemRepository) *ShoppingListService { +func NewShoppingListService(listRepo repository.ShoppingListRepository, itemRepo repository.ListItemRepository, eventBus *event.Broker) *ShoppingListService { return &ShoppingListService{ listRepo: listRepo, itemRepo: itemRepo, + eventBus: eventBus, } } @@ -43,6 +46,8 @@ func (s *ShoppingListService) CreateList(spaceID, name string) (*model.ShoppingL return nil, err } + s.eventBus.Publish(spaceID, "list_created", nil) + return list, nil } @@ -76,13 +81,23 @@ func (s *ShoppingListService) UpdateList(listID, name string) (*model.ShoppingLi } func (s *ShoppingListService) DeleteList(listID string) error { + // Need spaceID to publish event + list, err := s.listRepo.GetByID(listID) + if err != nil { + return err + } + // First delete all items in the list - err := s.itemRepo.DeleteByListID(listID) + 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) + err = s.listRepo.Delete(listID) + if err == nil { + s.eventBus.Publish(list.SpaceID, "list_deleted", nil) + } + return err } // Item methods @@ -108,6 +123,12 @@ func (s *ShoppingListService) AddItemToList(listID, name, createdBy string) (*mo return nil, err } + // Get Space ID + list, err := s.listRepo.GetByID(listID) + if err == nil { + s.eventBus.Publish(list.SpaceID, "item_added", nil) + } + return item, nil } @@ -138,9 +159,27 @@ func (s *ShoppingListService) UpdateItem(itemID, name string, isChecked bool) (* return nil, err } + // Get Space ID via List + list, err := s.listRepo.GetByID(item.ListID) + if err == nil { + s.eventBus.Publish(list.SpaceID, "item_updated", nil) + } + return item, nil } func (s *ShoppingListService) DeleteItem(itemID string) error { - return s.itemRepo.Delete(itemID) + item, err := s.itemRepo.GetByID(itemID) + if err != nil { + return err + } + + err = s.itemRepo.Delete(itemID) + if err == nil { + list, err := s.listRepo.GetByID(item.ListID) + if err == nil { + s.eventBus.Publish(list.SpaceID, "item_deleted", nil) + } + } + return err } diff --git a/internal/ui/components/expense/expense.templ b/internal/ui/components/expense/expense.templ index 2eac8cb..e9c483c 100644 --- a/internal/ui/components/expense/expense.templ +++ b/internal/ui/components/expense/expense.templ @@ -84,8 +84,15 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, lists []*model.Shopp } -templ BalanceCard(balance int, oob bool) { -
{ fmt.Sprintf("$%.2f", float64(balance)/100.0) }
diff --git a/internal/ui/layouts/space.templ b/internal/ui/layouts/space.templ
index ed0bf38..832725a 100644
--- a/internal/ui/layouts/space.templ
+++ b/internal/ui/layouts/space.templ
@@ -93,42 +93,44 @@ templ Space(title string, space *model.Space) {
}
}
@sidebar.Inset() {
- // Top Navigation Bar
- No expenses recorded yet. No expenses recorded yet. This list is empty. This list is empty.History
- History
+