Merge pull request 'fix: remove sse' (#7) from fix/sse into main

Reviewed-on: #7
This commit is contained in:
juancwu 2026-02-08 00:23:14 +00:00
commit a572048057
13 changed files with 23 additions and 231 deletions

View file

@ -32,14 +32,13 @@ tailwindcss -i ./assets/css/input.css -o ./assets/css/output.css --watch # watc
**Layered architecture**: handler → service → repository → DB **Layered architecture**: handler → service → repository → DB
- `cmd/server/main.go` — entry point, loads config, initializes app, starts server - `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/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/repository/` — data access with sqlx, interface-based
- `internal/model/` — data structs with `db:` tags - `internal/model/` — data structs with `db:` tags
- `internal/middleware/` — ordered chain: Config → Logging → NoCache → CSRF → Auth → URLPath - `internal/middleware/` — ordered chain: Config → Logging → NoCache → CSRF → Auth → URLPath
- `internal/routes/routes.go` — all route definitions with middleware wrapping - `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/ - `internal/ui/` — templ templates organized as pages/, components/, layouts/, blocks/
- `assets/` — static files (CSS, JS, fonts) embedded in binary via `go:embed` - `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`). **`?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. **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. **Auth flow**: JWT in HTTP-only cookies. Routes wrapped with `middleware.RequireAuth` and `middleware.RequireSpaceAccess` for space routes.

View file

@ -5,7 +5,6 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/config" "git.juancwu.dev/juancwu/budgit/internal/config"
"git.juancwu.dev/juancwu/budgit/internal/db" "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/repository"
"git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/service"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@ -14,7 +13,6 @@ import (
type App struct { type App struct {
Cfg *config.Config Cfg *config.Config
DB *sqlx.DB DB *sqlx.DB
EventBus *event.Broker
UserService *service.UserService UserService *service.UserService
AuthService *service.AuthService AuthService *service.AuthService
EmailService *service.EmailService EmailService *service.EmailService
@ -37,8 +35,6 @@ func New(cfg *config.Config) (*App, error) {
return nil, fmt.Errorf("failed to run migrations: %w", err) 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) emailClient := service.NewEmailClient(cfg.MailerSMTPHost, cfg.MailerSMTPPort, cfg.MailerIMAPHost, cfg.MailerIMAPPort, cfg.MailerUsername, cfg.MailerPassword)
// Repositories // Repositories
@ -75,14 +71,13 @@ func New(cfg *config.Config) (*App, error) {
) )
profileService := service.NewProfileService(profileRepository) profileService := service.NewProfileService(profileRepository)
tagService := service.NewTagService(tagRepository) tagService := service.NewTagService(tagRepository)
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository, eventBus) shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository)
expenseService := service.NewExpenseService(expenseRepository, eventBus) expenseService := service.NewExpenseService(expenseRepository)
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService) inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
return &App{ return &App{
Cfg: cfg, Cfg: cfg,
DB: database, DB: database,
EventBus: eventBus,
UserService: userService, UserService: userService,
AuthService: authService, AuthService: authService,
EmailService: emailService, EmailService: emailService,

View file

@ -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)
}

View file

@ -7,7 +7,6 @@ import (
"time" "time"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "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/model"
"git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui" "git.juancwu.dev/juancwu/budgit/internal/ui"
@ -24,17 +23,15 @@ type SpaceHandler struct {
listService *service.ShoppingListService listService *service.ShoppingListService
expenseService *service.ExpenseService expenseService *service.ExpenseService
inviteService *service.InviteService 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{ return &SpaceHandler{
spaceService: ss, spaceService: ss,
tagService: ts, tagService: ts,
listService: sls, listService: sls,
expenseService: es, expenseService: es,
inviteService: is, inviteService: is,
eventBus: eb,
} }
} }
@ -67,43 +64,6 @@ func (h *SpaceHandler) getListForSpace(w http.ResponseWriter, spaceID, listID st
return list 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) { func (h *SpaceHandler) DashboardPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID") spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID) space, err := h.spaceService.GetSpace(spaceID)

View file

@ -15,7 +15,7 @@ func SetupRoutes(a *app.App) http.Handler {
home := handler.NewHomeHandler() home := handler.NewHomeHandler()
dashboard := handler.NewDashboardHandler(a.SpaceService, a.ExpenseService) dashboard := handler.NewDashboardHandler(a.SpaceService, a.ExpenseService)
settings := handler.NewSettingsHandler(a.AuthService, a.UserService) 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() mux := http.NewServeMux()
@ -64,11 +64,6 @@ func SetupRoutes(a *app.App) http.Handler {
spaceDashboardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(spaceDashboardHandler) spaceDashboardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(spaceDashboardHandler)
mux.Handle("GET /app/spaces/{spaceID}", spaceDashboardWithAccess) 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) listsPageHandler := middleware.RequireAuth(space.ListsPage)
listsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsPageHandler) listsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsPageHandler)
mux.Handle("GET /app/spaces/{spaceID}/lists", listsPageWithAccess) mux.Handle("GET /app/spaces/{spaceID}/lists", listsPageWithAccess)

View file

@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"time" "time"
"git.juancwu.dev/juancwu/budgit/internal/event"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
@ -33,13 +32,11 @@ type UpdateExpenseDTO struct {
type ExpenseService struct { type ExpenseService struct {
expenseRepo repository.ExpenseRepository expenseRepo repository.ExpenseRepository
eventBus *event.Broker
} }
func NewExpenseService(expenseRepo repository.ExpenseRepository, eventBus *event.Broker) *ExpenseService { func NewExpenseService(expenseRepo repository.ExpenseRepository) *ExpenseService {
return &ExpenseService{ return &ExpenseService{
expenseRepo: expenseRepo, expenseRepo: expenseRepo,
eventBus: eventBus,
} }
} }
@ -69,14 +66,6 @@ func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, er
return nil, err 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 return expense, nil
} }

View file

@ -5,7 +5,6 @@ import (
"strings" "strings"
"time" "time"
"git.juancwu.dev/juancwu/budgit/internal/event"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
@ -14,14 +13,12 @@ import (
type ShoppingListService struct { type ShoppingListService struct {
listRepo repository.ShoppingListRepository listRepo repository.ShoppingListRepository
itemRepo repository.ListItemRepository 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{ return &ShoppingListService{
listRepo: listRepo, listRepo: listRepo,
itemRepo: itemRepo, itemRepo: itemRepo,
eventBus: eventBus,
} }
} }
@ -46,8 +43,6 @@ func (s *ShoppingListService) CreateList(spaceID, name string) (*model.ShoppingL
return nil, err return nil, err
} }
s.eventBus.Publish(spaceID, "list_created", nil)
return list, nil return list, nil
} }
@ -81,23 +76,13 @@ func (s *ShoppingListService) UpdateList(listID, name string) (*model.ShoppingLi
} }
func (s *ShoppingListService) DeleteList(listID string) error { 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 // First delete all items in the list
err = s.itemRepo.DeleteByListID(listID) err := s.itemRepo.DeleteByListID(listID)
if err != nil { if err != nil {
return fmt.Errorf("failed to delete items in list: %w", err) return fmt.Errorf("failed to delete items in list: %w", err)
} }
// Then delete the list itself // Then delete the list itself
err = s.listRepo.Delete(listID) return s.listRepo.Delete(listID)
if err == nil {
s.eventBus.Publish(list.SpaceID, "list_deleted", nil)
}
return err
} }
// Item methods // Item methods
@ -123,12 +108,6 @@ func (s *ShoppingListService) AddItemToList(listID, name, createdBy string) (*mo
return nil, err 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 return item, nil
} }
@ -187,12 +166,6 @@ func (s *ShoppingListService) UpdateItem(itemID, name string, isChecked bool) (*
return nil, err 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 return item, nil
} }
@ -204,17 +177,7 @@ func (s *ShoppingListService) CheckItem(itemID string) error {
item.IsChecked = true item.IsChecked = true
err = s.itemRepo.Update(item) return 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
} }
func (s *ShoppingListService) GetListsWithUncheckedItems(spaceID string) ([]model.ListWithUncheckedItems, error) { 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 { func (s *ShoppingListService) DeleteItem(itemID string) error {
item, err := s.itemRepo.GetByID(itemID) return s.itemRepo.Delete(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
} }

View file

@ -336,9 +336,6 @@ templ BalanceCard(spaceID string, balance int, oob bool) {
<div <div
id="balance-card" id="balance-card"
class="border rounded-lg p-4 bg-card text-card-foreground" class="border rounded-lg p-4 bg-card text-card-foreground"
hx-get={ "/app/spaces/" + spaceID + "/components/balance" }
hx-trigger="sse:balance_changed"
hx-swap="outerHTML"
if oob { if oob {
hx-swap-oob="true" hx-swap-oob="true"
} }

View file

@ -103,7 +103,7 @@ templ Space(title string, space *model.Space) {
} }
} }
@sidebar.Inset() { @sidebar.Inset() {
<div hx-sse={ "connect:/app/spaces/" + space.ID + "/stream" } class="flex flex-col h-full"> <div class="flex flex-col h-full">
// Top Navigation Bar // Top Navigation Bar
<header class="sticky top-0 z-10 border-b bg-background"> <header class="sticky top-0 z-10 border-b bg-background">
<div class="flex h-14 items-center px-6"> <div class="flex h-14 items-center px-6">

View file

@ -32,23 +32,18 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, b
} }
} }
@expense.AddExpenseForm(expense.AddExpenseFormProps{ @expense.AddExpenseForm(expense.AddExpenseFormProps{
Space: space, Space: space,
Tags: tags, Tags: tags,
ListsWithItems: listsWithItems, ListsWithItems: listsWithItems,
DialogID: "add-expense-dialog", DialogID: "add-expense-dialog",
}) })
} }
} }
</div> </div>
// Balance Card // Balance Card
@expense.BalanceCard(space.ID, balance, false) @expense.BalanceCard(space.ID, balance, false)
// List of expenses // List of expenses
<div <div class="border rounded-lg">
class="border rounded-lg"
hx-get={ "/app/spaces/" + space.ID + "/components/expenses" }
hx-trigger="sse:expenses_updated"
hx-swap="innerHTML"
>
@ExpensesListContent(space.ID, expenses) @ExpensesListContent(space.ID, expenses)
</div> </div>
</div> </div>

View file

@ -72,9 +72,6 @@ templ SpaceListDetailPage(space *model.Space, list *model.ShoppingList, items []
<div <div
id="items-container" id="items-container"
class="border rounded-lg" class="border rounded-lg"
hx-get={ "/app/spaces/" + space.ID + "/lists/" + list.ID + "/items" }
hx-trigger="sse:item_added, sse:item_updated, sse:item_deleted"
hx-swap="innerHTML"
> >
@ShoppingListItems(space.ID, items) @ShoppingListItems(space.ID, items)
</div> </div>

View file

@ -36,9 +36,6 @@ templ SpaceListsPage(space *model.Space, cards []model.ListCardData) {
<div <div
id="lists-container" id="lists-container"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
hx-get={ "/app/spaces/" + space.ID + "/components/lists" }
hx-trigger="sse:list_created, sse:list_deleted"
hx-swap="innerHTML"
> >
@ListsContainer(space.ID, cards) @ListsContainer(space.ID, cards)
</div> </div>

View file

@ -36,7 +36,10 @@ templ SpaceTagsPage(space *model.Space, tags []*model.Tag) {
Create Create
} }
</form> </form>
<div id="tags-container" class="flex flex-wrap gap-2"> <div
id="tags-container"
class="flex flex-wrap gap-2"
>
for _, t := range tags { for _, t := range tags {
@tag.Tag(t) @tag.Tag(t)
} }