diff --git a/chat/chat.go b/chat/chat.go index 632f919..c5b98be 100644 --- a/chat/chat.go +++ b/chat/chat.go @@ -3,11 +3,15 @@ package chat import ( + "context" "encoding/json" + "slices" "sync" "github.com/nats-io/nats.go" "github.com/rs/zerolog/log" + + "github.com/ryanhamamura/games/db/repository" ) // Message is the wire format for chat messages over NATS. @@ -21,26 +25,47 @@ type Message struct { const maxMessages = 50 // Room manages an in-memory message buffer and NATS pub/sub for a single -// chat room (typically one per game). +// chat room (typically one per game). When created with NewPersistentRoom, +// messages are automatically loaded from and saved to the database. type Room struct { subject string nc *nats.Conn messages []Message mu sync.Mutex + + // Optional persistence; nil for ephemeral rooms (e.g. snake). + queries *repository.Queries + roomID string } -// NewRoom creates a chat room that publishes and subscribes on the given -// NATS subject (e.g. "chat.abc123"). -func NewRoom(nc *nats.Conn, subject string, initial []Message) *Room { +// NewRoom creates an ephemeral chat room with no database persistence. +func NewRoom(nc *nats.Conn, subject string) *Room { return &Room{ - subject: subject, - nc: nc, - messages: initial, + subject: subject, + nc: nc, } } -// Send publishes a message to the room's NATS subject. +// NewPersistentRoom creates a chat room backed by the database. It loads +// existing messages on creation and auto-saves new messages on Send. +func NewPersistentRoom(nc *nats.Conn, subject string, queries *repository.Queries, roomID string) *Room { + r := &Room{ + subject: subject, + nc: nc, + queries: queries, + roomID: roomID, + } + r.messages = r.loadMessages() + return r +} + +// Send publishes a message to the room's NATS subject and persists it +// if the room is backed by a database. func (r *Room) Send(msg Message) { + if r.queries != nil { + r.saveMessage(msg) + } + data, err := json.Marshal(msg) if err != nil { log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message") @@ -90,3 +115,35 @@ func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) { } return ch, sub, nil } + +func (r *Room) saveMessage(msg Message) { + err := r.queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ + GameID: r.roomID, + Nickname: msg.Nickname, + Color: int64(msg.Slot), + Message: msg.Message, + CreatedAt: msg.Time, + }) + if err != nil { + log.Error().Err(err).Str("room_id", r.roomID).Msg("failed to save chat message") + } +} + +func (r *Room) loadMessages() []Message { + rows, err := r.queries.GetChatMessages(context.Background(), r.roomID) + if err != nil { + return nil + } + msgs := make([]Message, len(rows)) + for i, row := range rows { + msgs[i] = Message{ + Nickname: row.Nickname, + Slot: int(row.Color), + Message: row.Message, + Time: row.CreatedAt, + } + } + // DB returns newest-first; reverse for chronological display + slices.Reverse(msgs) + return msgs +} diff --git a/chat/persist.go b/chat/persist.go deleted file mode 100644 index 45b6ee7..0000000 --- a/chat/persist.go +++ /dev/null @@ -1,45 +0,0 @@ -package chat - -import ( - "context" - "slices" - - "github.com/ryanhamamura/games/db/repository" - - "github.com/rs/zerolog/log" -) - -// SaveMessage persists a chat message to the database. -func SaveMessage(queries *repository.Queries, roomID string, msg Message) { - err := queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ - GameID: roomID, - Nickname: msg.Nickname, - Color: int64(msg.Slot), - Message: msg.Message, - CreatedAt: msg.Time, - }) - if err != nil { - log.Error().Err(err).Str("room_id", roomID).Msg("failed to save chat message") - } -} - -// LoadMessages loads persisted chat messages for a room, returning them -// in chronological order (oldest first). -func LoadMessages(queries *repository.Queries, roomID string) []Message { - rows, err := queries.GetChatMessages(context.Background(), roomID) - if err != nil { - return nil - } - msgs := make([]Message, len(rows)) - for i, r := range rows { - msgs[i] = Message{ - Nickname: r.Nickname, - Slot: int(r.Color), - Message: r.Message, - Time: r.CreatedAt, - } - } - // DB returns newest-first; reverse for chronological display - slices.Reverse(msgs) - return msgs -} diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index 79461d9..777b0c6 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -85,9 +85,9 @@ func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repo } g := gi.GetGame() - msgs := chat.LoadMessages(queries, gameID) + room := chat.NewPersistentRoom(nil, "", queries, gameID) - if err := pages.GamePage(g, myColor, msgs, c4ChatConfig(gameID)).Render(r.Context(), w); err != nil { + if err := pages.GamePage(g, myColor, room.Messages(), c4ChatConfig(gameID)).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } @@ -111,7 +111,7 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag )) chatCfg := c4ChatConfig(gameID) - room := chat.NewRoom(nc, "connect4.chat."+gameID, chat.LoadMessages(queries, gameID)) + room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID) // Send initial render sendGameComponents(sse, gi, myColor, room, chatCfg) @@ -228,9 +228,7 @@ func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager Message: signals.ChatMsg, Time: time.Now().UnixMilli(), } - chat.SaveMessage(queries, gameID, msg) - - room := chat.NewRoom(nc, "connect4.chat."+gameID, nil) + room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID) room.Send(msg) sse := datastar.NewSSE(w, r) diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go index 5f518dd..e42471d 100644 --- a/features/snakegame/handlers.go +++ b/features/snakegame/handlers.go @@ -126,7 +126,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess var room *chat.Room if sg.Mode == snake.ModeMultiplayer { - room = chat.NewRoom(nc, "snake.chat."+gameID, nil) + room = chat.NewRoom(nc, "snake.chat."+gameID) chatCh, chatSub, err = room.Subscribe() if err != nil { return @@ -247,7 +247,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session Message: signals.ChatMsg, } - room := chat.NewRoom(nc, "snake.chat."+gameID, nil) + room := chat.NewRoom(nc, "snake.chat."+gameID) room.Send(msg) sse := datastar.NewSSE(w, r)