This commit is contained in:
juancwu 2026-01-14 21:51:08 +00:00
commit dd7f2ebe3e
15 changed files with 373 additions and 70 deletions

View file

@ -2,5 +2,5 @@ package assets
import "embed"
//go:embed js/* css/*
//go:embed js/* css/* fonts/*
var AssetsFS embed.FS

Binary file not shown.

Binary file not shown.

2
go.mod
View file

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

View file

@ -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,

84
internal/event/bus.go Normal file
View 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)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -84,8 +84,15 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, lists []*model.Shopp
</form>
}
templ BalanceCard(balance int, oob bool) {
<div id="balance-card" class="border rounded-lg p-4 bg-card text-card-foreground" hx-swap-oob?={ oob }>
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 }
hx-get={ "/app/spaces/" + spaceID + "/components/balance" }
hx-trigger="sse:balance_changed"
hx-swap="outerHTML"
>
<h2 class="text-lg font-semibold">Current Balance</h2>
<p class={ "text-3xl font-bold", templ.KV("text-destructive", balance < 0) }>
{ fmt.Sprintf("$%.2f", float64(balance)/100.0) }

View file

@ -93,6 +93,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">
// Top Navigation Bar
<header class="sticky top-0 z-10 border-b bg-background">
<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">
{ children... }
</main>
</div>
}
}
}

View file

@ -33,10 +33,23 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
</div>
// Balance Card
@expense.BalanceCard(balance, false)
@expense.BalanceCard(space.ID, balance, false)
// 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>
<div class="divide-y">
if len(expenses) == 0 {
@ -46,9 +59,6 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
@ExpenseListItem(expense)
}
</div>
</div>
</div>
}
}
templ ExpenseListItem(expense *model.Expense) {
@ -73,5 +83,5 @@ templ ExpenseListItem(expense *model.Expense) {
templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) {
@ExpenseListItem(newExpense)
@expense.BalanceCard(balance, true)
@expense.BalanceCard(newExpense.SpaceID, balance, true)
}

View file

@ -31,15 +31,25 @@ templ SpaceListDetailPage(space *model.Space, list *model.ShoppingList, items []
Add Item
}
</form>
<div id="items-container" class="border rounded-lg">
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(space.ID, item)
}
}
<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>
</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)
}
}
}

View file

@ -33,11 +33,21 @@ templ SpaceListsPage(space *model.Space, lists []*model.ShoppingList) {
Create
}
</form>
<div id="lists-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, list := range lists {
@shoppinglist.ListItem(list)
}
<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(lists)
</div>
</div>
}
}
templ ListsContainer(lists []*model.ShoppingList) {
for _, list := range lists {
@shoppinglist.ListItem(list)
}
}