fix: remove sse #7
13 changed files with 23 additions and 231 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -336,9 +336,6 @@ templ BalanceCard(spaceID string, balance int, oob bool) {
|
|||
<div
|
||||
id="balance-card"
|
||||
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 {
|
||||
hx-swap-oob="true"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ templ Space(title string, space *model.Space) {
|
|||
}
|
||||
}
|
||||
@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
|
||||
<header class="sticky top-0 z-10 border-b bg-background">
|
||||
<div class="flex h-14 items-center px-6">
|
||||
|
|
|
|||
|
|
@ -43,12 +43,7 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, b
|
|||
// Balance Card
|
||||
@expense.BalanceCard(space.ID, balance, false)
|
||||
// List of expenses
|
||||
<div
|
||||
class="border rounded-lg"
|
||||
hx-get={ "/app/spaces/" + space.ID + "/components/expenses" }
|
||||
hx-trigger="sse:expenses_updated"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div class="border rounded-lg">
|
||||
@ExpensesListContent(space.ID, expenses)
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -72,9 +72,6 @@ templ SpaceListDetailPage(space *model.Space, list *model.ShoppingList, items []
|
|||
<div
|
||||
id="items-container"
|
||||
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)
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,9 +36,6 @@ templ SpaceListsPage(space *model.Space, cards []model.ListCardData) {
|
|||
<div
|
||||
id="lists-container"
|
||||
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)
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ templ SpaceTagsPage(space *model.Space, tags []*model.Tag) {
|
|||
Create
|
||||
}
|
||||
</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 {
|
||||
@tag.Tag(t)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue