diff --git a/CLAUDE.md b/CLAUDE.md index a53c015..f4f5bb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,14 +32,13 @@ tailwindcss -i ./assets/css/input.css -o ./assets/css/output.css --watch # watc **Layered architecture**: handler → service → repository → DB - `cmd/server/main.go` — entry point, loads config, initializes app, starts server -- `internal/app/` — dependency injection, wires all repositories/services/event broker +- `internal/app/` — dependency injection, wires all repositories/services - `internal/handler/` — HTTP handlers grouped by domain (auth, space, dashboard, home) -- `internal/service/` — business logic, event publishing +- `internal/service/` — business logic - `internal/repository/` — data access with sqlx, interface-based - `internal/model/` — data structs with `db:` tags - `internal/middleware/` — ordered chain: Config → Logging → NoCache → CSRF → Auth → URLPath - `internal/routes/routes.go` — all route definitions with middleware wrapping -- `internal/event/` — SSE pub/sub broker, space-scoped channels - `internal/ui/` — templ templates organized as pages/, components/, layouts/, blocks/ - `assets/` — static files (CSS, JS, fonts) embedded in binary via `go:embed` @@ -49,8 +48,6 @@ tailwindcss -i ./assets/css/input.css -o ./assets/css/output.css --watch # watc **`?from=card` query param**: Handlers check this to return different component variants for card vs detail page contexts (e.g., `UpdateList`, `DeleteList`, `ToggleItem`). -**SSE events**: `event.Broker` publishes space-scoped events. Templates subscribe via `hx-sse="connect:/app/spaces/{id}/stream"` and trigger refreshes with `hx-trigger="sse:event_name"`. - **CSRF**: Double-submit cookie pattern. Use `@csrf.Token()` in every form. **Auth flow**: JWT in HTTP-only cookies. Routes wrapped with `middleware.RequireAuth` and `middleware.RequireSpaceAccess` for space routes. diff --git a/internal/app/app.go b/internal/app/app.go index a46ac01..a813014 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,7 +5,6 @@ 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" @@ -14,7 +13,6 @@ import ( type App struct { Cfg *config.Config DB *sqlx.DB - EventBus *event.Broker UserService *service.UserService AuthService *service.AuthService EmailService *service.EmailService @@ -37,8 +35,6 @@ 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 @@ -75,14 +71,13 @@ func New(cfg *config.Config) (*App, error) { ) profileService := service.NewProfileService(profileRepository) tagService := service.NewTagService(tagRepository) - shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository, eventBus) - expenseService := service.NewExpenseService(expenseRepository, eventBus) + shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository) + expenseService := service.NewExpenseService(expenseRepository) 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 deleted file mode 100644 index e503119..0000000 --- a/internal/event/bus.go +++ /dev/null @@ -1,84 +0,0 @@ -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 cc8528e..9a58f4c 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -7,7 +7,6 @@ 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" @@ -24,17 +23,15 @@ 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, eb *event.Broker) *SpaceHandler { +func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService, is *service.InviteService) *SpaceHandler { return &SpaceHandler{ spaceService: ss, tagService: ts, listService: sls, expenseService: es, inviteService: is, - eventBus: eb, } } @@ -67,43 +64,6 @@ func (h *SpaceHandler) getListForSpace(w http.ResponseWriter, spaceID, listID st return list } -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) diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 55b8ba1..24fdb2c 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -15,7 +15,7 @@ func SetupRoutes(a *app.App) http.Handler { home := handler.NewHomeHandler() dashboard := handler.NewDashboardHandler(a.SpaceService, a.ExpenseService) settings := handler.NewSettingsHandler(a.AuthService, a.UserService) - space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.EventBus) + space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService) mux := http.NewServeMux() @@ -64,11 +64,6 @@ 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) diff --git a/internal/service/expense.go b/internal/service/expense.go index 9d367b4..d016eab 100644 --- a/internal/service/expense.go +++ b/internal/service/expense.go @@ -4,7 +4,6 @@ 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" @@ -33,13 +32,11 @@ type UpdateExpenseDTO struct { type ExpenseService struct { expenseRepo repository.ExpenseRepository - eventBus *event.Broker } -func NewExpenseService(expenseRepo repository.ExpenseRepository, eventBus *event.Broker) *ExpenseService { +func NewExpenseService(expenseRepo repository.ExpenseRepository) *ExpenseService { return &ExpenseService{ expenseRepo: expenseRepo, - eventBus: eventBus, } } @@ -69,14 +66,6 @@ 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 10f977d..1f3e7a9 100644 --- a/internal/service/shopping_list.go +++ b/internal/service/shopping_list.go @@ -5,7 +5,6 @@ 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" @@ -14,14 +13,12 @@ import ( type ShoppingListService struct { listRepo repository.ShoppingListRepository itemRepo repository.ListItemRepository - eventBus *event.Broker } -func NewShoppingListService(listRepo repository.ShoppingListRepository, itemRepo repository.ListItemRepository, eventBus *event.Broker) *ShoppingListService { +func NewShoppingListService(listRepo repository.ShoppingListRepository, itemRepo repository.ListItemRepository) *ShoppingListService { return &ShoppingListService{ listRepo: listRepo, itemRepo: itemRepo, - eventBus: eventBus, } } @@ -46,8 +43,6 @@ func (s *ShoppingListService) CreateList(spaceID, name string) (*model.ShoppingL return nil, err } - s.eventBus.Publish(spaceID, "list_created", nil) - return list, nil } @@ -81,23 +76,13 @@ 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 - err = s.listRepo.Delete(listID) - if err == nil { - s.eventBus.Publish(list.SpaceID, "list_deleted", nil) - } - return err + return s.listRepo.Delete(listID) } // Item methods @@ -123,12 +108,6 @@ 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 } @@ -187,12 +166,6 @@ 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 } @@ -204,17 +177,7 @@ func (s *ShoppingListService) CheckItem(itemID string) error { item.IsChecked = true - err = s.itemRepo.Update(item) - if err != nil { - return err - } - - list, err := s.listRepo.GetByID(item.ListID) - if err == nil { - s.eventBus.Publish(list.SpaceID, "item_updated", nil) - } - - return nil + return s.itemRepo.Update(item) } func (s *ShoppingListService) GetListsWithUncheckedItems(spaceID string) ([]model.ListWithUncheckedItems, error) { @@ -249,17 +212,5 @@ func (s *ShoppingListService) GetListsWithUncheckedItems(spaceID string) ([]mode } func (s *ShoppingListService) DeleteItem(itemID string) error { - 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 + return s.itemRepo.Delete(itemID) } diff --git a/internal/ui/components/expense/expense.templ b/internal/ui/components/expense/expense.templ index 2131d0c..97ea942 100644 --- a/internal/ui/components/expense/expense.templ +++ b/internal/ui/components/expense/expense.templ @@ -336,9 +336,6 @@ templ BalanceCard(spaceID string, balance int, oob bool) {
+
// Top Navigation Bar
diff --git a/internal/ui/pages/app_space_expenses.templ b/internal/ui/pages/app_space_expenses.templ index 1964c16..102670e 100644 --- a/internal/ui/pages/app_space_expenses.templ +++ b/internal/ui/pages/app_space_expenses.templ @@ -32,23 +32,18 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, b } } @expense.AddExpenseForm(expense.AddExpenseFormProps{ - Space: space, - Tags: tags, - ListsWithItems: listsWithItems, - DialogID: "add-expense-dialog", - }) + Space: space, + Tags: tags, + ListsWithItems: listsWithItems, + DialogID: "add-expense-dialog", + }) } }
// Balance Card @expense.BalanceCard(space.ID, balance, false) // List of expenses -
+
@ExpensesListContent(space.ID, expenses)
diff --git a/internal/ui/pages/app_space_list_detail.templ b/internal/ui/pages/app_space_list_detail.templ index 4560f0b..ba55cbb 100644 --- a/internal/ui/pages/app_space_list_detail.templ +++ b/internal/ui/pages/app_space_list_detail.templ @@ -72,9 +72,6 @@ templ SpaceListDetailPage(space *model.Space, list *model.ShoppingList, items []
@ShoppingListItems(space.ID, items)
diff --git a/internal/ui/pages/app_space_lists.templ b/internal/ui/pages/app_space_lists.templ index f1c8483..a2b1756 100644 --- a/internal/ui/pages/app_space_lists.templ +++ b/internal/ui/pages/app_space_lists.templ @@ -36,9 +36,6 @@ templ SpaceListsPage(space *model.Space, cards []model.ListCardData) {
@ListsContainer(space.ID, cards)
diff --git a/internal/ui/pages/app_space_tags.templ b/internal/ui/pages/app_space_tags.templ index 2a7c581..4b1ffa7 100644 --- a/internal/ui/pages/app_space_tags.templ +++ b/internal/ui/pages/app_space_tags.templ @@ -36,7 +36,10 @@ templ SpaceTagsPage(space *model.Space, tags []*model.Tag) { Create } -
+
for _, t := range tags { @tag.Tag(t) }