add SSE
This commit is contained in:
parent
4d6e6799a0
commit
dd7f2ebe3e
15 changed files with 373 additions and 70 deletions
|
|
@ -2,5 +2,5 @@ package assets
|
||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
|
|
||||||
//go:embed js/* css/*
|
//go:embed js/* css/* fonts/*
|
||||||
var AssetsFS embed.FS
|
var AssetsFS embed.FS
|
||||||
|
|
|
||||||
BIN
assets/fonts/geist/geist-mono-variable.woff2
Normal file
BIN
assets/fonts/geist/geist-mono-variable.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/geist/geist-variable.woff2
Normal file
BIN
assets/fonts/geist/geist-variable.woff2
Normal file
Binary file not shown.
2
go.mod
2
go.mod
|
|
@ -8,6 +8,7 @@ require (
|
||||||
github.com/alexedwards/argon2id v1.0.0
|
github.com/alexedwards/argon2id v1.0.0
|
||||||
github.com/emersion/go-imap v1.2.1
|
github.com/emersion/go-imap v1.2.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
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/jackc/pgx/v5 v5.7.6
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
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/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||||
github.com/golang/protobuf v1.5.3 // 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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ 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"
|
||||||
|
|
@ -13,6 +14,7 @@ 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
|
||||||
|
|
@ -35,6 +37,8 @@ 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
|
||||||
|
|
@ -71,13 +75,14 @@ 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)
|
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository, eventBus)
|
||||||
expenseService := service.NewExpenseService(expenseRepository)
|
expenseService := service.NewExpenseService(expenseRepository, eventBus)
|
||||||
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,
|
||||||
|
|
|
||||||
84
internal/event/bus.go
Normal file
84
internal/event/bus.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -7,11 +7,14 @@ 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"
|
||||||
|
"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/shoppinglist"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tag"
|
"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"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -21,15 +24,54 @@ 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) *SpaceHandler {
|
func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService, is *service.InviteService, eb *event.Broker) *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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Return a nice UI response (toast or list update)
|
ui.Render(w, r, toast.Toast(toast.Props{
|
||||||
w.Write([]byte("Invitation sent!"))
|
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) {
|
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)
|
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))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
auth := handler.NewAuthHandler(a.AuthService, a.InviteService)
|
auth := handler.NewAuthHandler(a.AuthService, a.InviteService)
|
||||||
home := handler.NewHomeHandler()
|
home := handler.NewHomeHandler()
|
||||||
dashboard := handler.NewDashboardHandler()
|
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()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
|
@ -57,6 +57,11 @@ 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)
|
||||||
|
|
@ -103,6 +108,23 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler)
|
createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler)
|
||||||
mux.Handle("POST /app/spaces/{spaceID}/expenses", createExpenseWithAccess)
|
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
|
// Invite routes
|
||||||
createInviteHandler := middleware.RequireAuth(space.CreateInvite)
|
createInviteHandler := middleware.RequireAuth(space.CreateInvite)
|
||||||
createInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createInviteHandler)
|
createInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createInviteHandler)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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"
|
||||||
|
|
@ -22,10 +23,14 @@ type CreateExpenseDTO struct {
|
||||||
|
|
||||||
type ExpenseService struct {
|
type ExpenseService struct {
|
||||||
expenseRepo repository.ExpenseRepository
|
expenseRepo repository.ExpenseRepository
|
||||||
|
eventBus *event.Broker
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExpenseService(expenseRepo repository.ExpenseRepository) *ExpenseService {
|
func NewExpenseService(expenseRepo repository.ExpenseRepository, eventBus *event.Broker) *ExpenseService {
|
||||||
return &ExpenseService{expenseRepo: expenseRepo}
|
return &ExpenseService{
|
||||||
|
expenseRepo: expenseRepo,
|
||||||
|
eventBus: eventBus,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, error) {
|
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
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ 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"
|
||||||
|
|
@ -13,12 +14,14 @@ 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) *ShoppingListService {
|
func NewShoppingListService(listRepo repository.ShoppingListRepository, itemRepo repository.ListItemRepository, eventBus *event.Broker) *ShoppingListService {
|
||||||
return &ShoppingListService{
|
return &ShoppingListService{
|
||||||
listRepo: listRepo,
|
listRepo: listRepo,
|
||||||
itemRepo: itemRepo,
|
itemRepo: itemRepo,
|
||||||
|
eventBus: eventBus,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +46,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,13 +81,23 @@ 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
|
||||||
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
|
// Item methods
|
||||||
|
|
@ -108,6 +123,12 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,9 +159,27 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ShoppingListService) DeleteItem(itemID string) error {
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,15 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, lists []*model.Shopp
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ BalanceCard(balance int, oob bool) {
|
templ BalanceCard(spaceID string, balance int, oob bool) {
|
||||||
<div id="balance-card" class="border rounded-lg p-4 bg-card text-card-foreground" hx-swap-oob?={ oob }>
|
<div
|
||||||
|
id="balance-card"
|
||||||
|
class="border rounded-lg p-4 bg-card text-card-foreground"
|
||||||
|
hx-swap-oob?={ oob }
|
||||||
|
hx-get={ "/app/spaces/" + spaceID + "/components/balance" }
|
||||||
|
hx-trigger="sse:balance_changed"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
<h2 class="text-lg font-semibold">Current Balance</h2>
|
<h2 class="text-lg font-semibold">Current Balance</h2>
|
||||||
<p class={ "text-3xl font-bold", templ.KV("text-destructive", balance < 0) }>
|
<p class={ "text-3xl font-bold", templ.KV("text-destructive", balance < 0) }>
|
||||||
{ fmt.Sprintf("$%.2f", float64(balance)/100.0) }
|
{ fmt.Sprintf("$%.2f", float64(balance)/100.0) }
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,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">
|
||||||
// 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">
|
||||||
|
|
@ -129,6 +130,7 @@ templ Space(title string, space *model.Space) {
|
||||||
<main class="flex-1 p-6">
|
<main class="flex-1 p-6">
|
||||||
{ children... }
|
{ children... }
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,23 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Balance Card
|
// Balance Card
|
||||||
@expense.BalanceCard(balance, false)
|
@expense.BalanceCard(space.ID, balance, false)
|
||||||
|
|
||||||
// List of expenses
|
// List of expenses
|
||||||
<div id="expenses-list" class="border rounded-lg">
|
<div
|
||||||
|
id="expenses-list"
|
||||||
|
class="border rounded-lg"
|
||||||
|
hx-get={ "/app/spaces/" + space.ID + "/components/expenses" }
|
||||||
|
hx-trigger="sse:expenses_updated"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
@ExpensesListContent(expenses)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ ExpensesListContent(expenses []*model.Expense) {
|
||||||
<h2 class="text-lg font-semibold p-4">History</h2>
|
<h2 class="text-lg font-semibold p-4">History</h2>
|
||||||
<div class="divide-y">
|
<div class="divide-y">
|
||||||
if len(expenses) == 0 {
|
if len(expenses) == 0 {
|
||||||
|
|
@ -46,9 +59,6 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
|
||||||
@ExpenseListItem(expense)
|
@ExpenseListItem(expense)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ExpenseListItem(expense *model.Expense) {
|
templ ExpenseListItem(expense *model.Expense) {
|
||||||
|
|
@ -73,5 +83,5 @@ templ ExpenseListItem(expense *model.Expense) {
|
||||||
|
|
||||||
templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) {
|
templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) {
|
||||||
@ExpenseListItem(newExpense)
|
@ExpenseListItem(newExpense)
|
||||||
@expense.BalanceCard(balance, true)
|
@expense.BalanceCard(newExpense.SpaceID, balance, true)
|
||||||
}
|
}
|
||||||
|
|
@ -31,15 +31,25 @@ templ SpaceListDetailPage(space *model.Space, list *model.ShoppingList, items []
|
||||||
Add Item
|
Add Item
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
<div id="items-container" class="border rounded-lg">
|
<div
|
||||||
if len(items) == 0 {
|
id="items-container"
|
||||||
<p class="text-center text-muted-foreground p-8">This list is empty.</p>
|
class="border rounded-lg"
|
||||||
} else {
|
hx-get={ "/app/spaces/" + space.ID + "/lists/" + list.ID + "/items" }
|
||||||
for _, item := range items {
|
hx-trigger="sse:item_added, sse:item_updated, sse:item_deleted"
|
||||||
@shoppinglist.ItemDetail(space.ID, item)
|
hx-swap="innerHTML"
|
||||||
}
|
>
|
||||||
}
|
@ShoppingListItems(space.ID, items)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ ShoppingListItems(spaceID string, items []*model.ListItem) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
<p class="text-center text-muted-foreground p-8">This list is empty.</p>
|
||||||
|
} else {
|
||||||
|
for _, item := range items {
|
||||||
|
@shoppinglist.ItemDetail(spaceID, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,21 @@ templ SpaceListsPage(space *model.Space, lists []*model.ShoppingList) {
|
||||||
Create
|
Create
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
<div id="lists-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div
|
||||||
for _, list := range lists {
|
id="lists-container"
|
||||||
@shoppinglist.ListItem(list)
|
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(lists)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ ListsContainer(lists []*model.ShoppingList) {
|
||||||
|
for _, list := range lists {
|
||||||
|
@shoppinglist.ListItem(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue