diff --git a/cmd/server/main.go b/cmd/server/main.go index cfc620c..80dcc25 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -43,7 +43,7 @@ func main() { sessionStore := auth.NewSessionStore(database.DB, cfg) // WebSocket hub - hub := gateway.NewHub(logger) + hub := gateway.NewHub(database.DB, logger) go hub.Run() // Giphy client (nil if no API key) diff --git a/internal/db/db.go b/internal/db/db.go index c4c3517..b2e98d2 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -88,6 +88,7 @@ CREATE TABLE IF NOT EXISTS messages ( channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, content TEXT NOT NULL, + reply_to UUID REFERENCES messages(id) ON DELETE SET NULL, edited_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -112,4 +113,28 @@ CREATE TABLE IF NOT EXISTS member_roles ( CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id); + +CREATE TABLE IF NOT EXISTS reactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (message_id, user_id, emoji) +); + +CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id); + +CREATE TABLE IF NOT EXISTS invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(16) NOT NULL UNIQUE, + server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ, + max_uses INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); ` diff --git a/internal/gateway/client.go b/internal/gateway/client.go index ec19990..cac4301 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -61,6 +61,9 @@ func (c *Client) readPump() { continue } + // Record activity for idle detection. + c.Hub.RecordActivity(c.UserID) + // Handle client-sent events (e.g. TYPING_START, PRESENCE_UPDATE) // For now, broadcast them to all clients switch event.Type { diff --git a/internal/gateway/events.go b/internal/gateway/events.go index 8ac0ee7..db70e10 100644 --- a/internal/gateway/events.go +++ b/internal/gateway/events.go @@ -14,6 +14,8 @@ const ( EventMemberRemove = "SERVER_MEMBER_REMOVE" EventPresenceUpdate = "PRESENCE_UPDATE" EventTypingStart = "TYPING_START" + EventReactionAdd = "REACTION_ADD" + EventReactionRemove = "REACTION_REMOVE" EventVoiceJoin = "VOICE_JOIN" EventVoiceLeave = "VOICE_LEAVE" EventVoiceMute = "VOICE_MUTE" diff --git a/internal/gateway/hub.go b/internal/gateway/hub.go index 50651e0..3b86d2e 100644 --- a/internal/gateway/hub.go +++ b/internal/gateway/hub.go @@ -1,40 +1,70 @@ package gateway import ( + "context" + "database/sql" "encoding/json" "log/slog" "sync" + "time" ) +const ( + // idleTimeout is the duration of inactivity before a user is set to idle. + idleTimeout = 5 * time.Minute + // idleCheckInterval is how often the hub checks for idle users. + idleCheckInterval = 30 * time.Second +) + +// PresenceData is the data payload for PRESENCE_UPDATE events. +type PresenceData struct { + UserID string `json:"user_id"` + Status string `json:"status"` +} + // Hub maintains the set of active clients and broadcasts messages to them. type Hub struct { - mu sync.RWMutex - clients map[*Client]struct{} - register chan *Client - unregister chan *Client - broadcast chan []byte - logger *slog.Logger + mu sync.RWMutex + clients map[*Client]struct{} + presence map[string]string // userID -> status + lastActivity map[string]time.Time // userID -> last activity time + register chan *Client + unregister chan *Client + broadcast chan []byte + db *sql.DB + logger *slog.Logger } // NewHub creates a new Hub. -func NewHub(logger *slog.Logger) *Hub { +func NewHub(db *sql.DB, logger *slog.Logger) *Hub { return &Hub{ - clients: make(map[*Client]struct{}), - register: make(chan *Client), - unregister: make(chan *Client), - broadcast: make(chan []byte, 256), - logger: logger, + clients: make(map[*Client]struct{}), + presence: make(map[string]string), + lastActivity: make(map[string]time.Time), + register: make(chan *Client), + unregister: make(chan *Client), + broadcast: make(chan []byte, 256), + db: db, + logger: logger, } } // Run starts the hub's main event loop. Call this in a goroutine. func (h *Hub) Run() { + idleTicker := time.NewTicker(idleCheckInterval) + defer idleTicker.Stop() + for { select { case client := <-h.register: h.mu.Lock() h.clients[client] = struct{}{} + h.presence[client.UserID] = "online" + h.lastActivity[client.UserID] = time.Now() h.mu.Unlock() + + h.setUserStatus(client.UserID, "online") + h.broadcastPresence(client.UserID, "online") h.logger.Info("client connected", "user_id", client.UserID) case client := <-h.unregister: @@ -43,7 +73,19 @@ func (h *Hub) Run() { delete(h.clients, client) close(client.send) } + + // Check if user has any remaining connected clients. + hasOther := h.hasClientForUser(client.UserID) + if !hasOther { + delete(h.presence, client.UserID) + delete(h.lastActivity, client.UserID) + } h.mu.Unlock() + + if !hasOther { + h.setUserStatus(client.UserID, "offline") + h.broadcastPresence(client.UserID, "offline") + } h.logger.Info("client disconnected", "user_id", client.UserID) case message := <-h.broadcast: @@ -58,10 +100,87 @@ func (h *Hub) Run() { } } h.mu.RUnlock() + + case <-idleTicker.C: + h.checkIdleUsers() } } } +// hasClientForUser returns true if any connected client belongs to the given +// user. Caller must hold h.mu (at least for reading). +func (h *Hub) hasClientForUser(userID string) bool { + for c := range h.clients { + if c.UserID == userID { + return true + } + } + return false +} + +// RecordActivity updates the last-activity timestamp for a user. If the user +// was idle, it resets their status back to online and broadcasts the change. +// Safe to call from any goroutine. +func (h *Hub) RecordActivity(userID string) { + h.mu.Lock() + h.lastActivity[userID] = time.Now() + wasIdle := h.presence[userID] == "idle" + if wasIdle { + h.presence[userID] = "online" + } + h.mu.Unlock() + + if wasIdle { + h.setUserStatus(userID, "online") + h.broadcastPresence(userID, "online") + } +} + +// checkIdleUsers scans all tracked users and marks as idle any who have been +// inactive for longer than idleTimeout. +func (h *Hub) checkIdleUsers() { + h.mu.Lock() + now := time.Now() + var idleUsers []string + for userID, last := range h.lastActivity { + if h.presence[userID] == "online" && now.Sub(last) >= idleTimeout { + h.presence[userID] = "idle" + idleUsers = append(idleUsers, userID) + } + } + h.mu.Unlock() + + for _, userID := range idleUsers { + h.setUserStatus(userID, "idle") + h.broadcastPresence(userID, "idle") + } +} + +// setUserStatus persists the user's status to the database. +func (h *Hub) setUserStatus(userID, status string) { + if h.db == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := h.db.ExecContext(ctx, `UPDATE users SET status = $1 WHERE id = $2`, status, userID) + if err != nil { + h.logger.Error("failed to update user status", "user_id", userID, "status", status, "error", err) + } +} + +// broadcastPresence sends a PRESENCE_UPDATE event to all connected clients. +func (h *Hub) broadcastPresence(userID, status string) { + h.BroadcastEvent(Event{ + Type: EventPresenceUpdate, + Data: PresenceData{ + UserID: userID, + Status: status, + }, + }) +} + // Register queues a client for registration with the hub. func (h *Hub) Register(client *Client) { h.register <- client diff --git a/internal/invite/handlers.go b/internal/invite/handlers.go new file mode 100644 index 0000000..49b35d3 --- /dev/null +++ b/internal/invite/handlers.go @@ -0,0 +1,288 @@ +package invite + +import ( + "crypto/rand" + "database/sql" + "encoding/json" + "errors" + "math/big" + "net/http" + "time" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +const codeLength = 8 +const codeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + +type Handler struct { + db *sql.DB +} + +func NewHandler(db *sql.DB) *Handler { + return &Handler{db: db} +} + +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Post("/servers/{serverID}/invites", h.Create) + r.Get("/invites/{code}", h.Get) + r.Post("/invites/{code}/join", h.Join) +} + +type createInviteRequest struct { + ExpiresHours *int `json:"expires_hours"` + MaxUses *int `json:"max_uses"` +} + +type inviteResponse struct { + ID string `json:"id"` + Code string `json:"code"` + ServerID string `json:"server_id"` + ServerName string `json:"server_name"` + CreatorID string `json:"creator_id"` + MaxUses *int `json:"max_uses"` + UseCount int `json:"use_count"` + ExpiresAt *string `json:"expires_at"` + CreatedAt string `json:"created_at"` +} + +// generateCode generates a cryptographically random alphanumeric code of the given length. +func generateCode(length int) (string, error) { + result := make([]byte, length) + max := big.NewInt(int64(len(codeChars))) + for i := 0; i < length; i++ { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + result[i] = codeChars[n.Int64()] + } + return string(result), nil +} + +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + // Verify user is a member of the server + var exists bool + err := h.db.QueryRowContext(r.Context(), ` + SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2) + `, userID, serverID).Scan(&exists) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if !exists { + http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden) + return + } + + var req createInviteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // Allow empty body + req = createInviteRequest{} + } + + // Generate a unique code (retry on collision) + var code string + for i := 0; i < 5; i++ { + code, err = generateCode(codeLength) + if err != nil { + http.Error(w, `{"error":"failed to generate invite code"}`, http.StatusInternalServerError) + return + } + + // Check uniqueness + var dup bool + err = h.db.QueryRowContext(r.Context(), ` + SELECT EXISTS(SELECT 1 FROM invites WHERE code = $1) + `, code).Scan(&dup) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if !dup { + break + } + if i == 4 { + http.Error(w, `{"error":"failed to generate unique code"}`, http.StatusInternalServerError) + return + } + } + + // Compute expiration + var expiresAt *time.Time + if req.ExpiresHours != nil && *req.ExpiresHours > 0 { + t := time.Now().Add(time.Duration(*req.ExpiresHours) * time.Hour) + expiresAt = &t + } + + var maxUses *int + if req.MaxUses != nil && *req.MaxUses > 0 { + maxUses = req.MaxUses + } + + var invite inviteResponse + var expiresAtStr sql.NullString + var createdAtStr sql.NullString + + err = h.db.QueryRowContext(r.Context(), ` + INSERT INTO invites (code, server_id, creator_id, max_uses, expires_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, code, server_id, creator_id, max_uses, use_count, expires_at::text, created_at::text + `, code, serverID, userID, maxUses, expiresAt).Scan( + &invite.ID, &invite.Code, &invite.ServerID, &invite.CreatorID, + &invite.MaxUses, &invite.UseCount, &expiresAtStr, &createdAtStr, + ) + if err != nil { + http.Error(w, `{"error":"failed to create invite"}`, http.StatusInternalServerError) + return + } + if expiresAtStr.Valid { + invite.ExpiresAt = &expiresAtStr.String + } + invite.CreatedAt = createdAtStr.String + + // Fetch server name + err = h.db.QueryRowContext(r.Context(), ` + SELECT name FROM servers WHERE id = $1 + `, serverID).Scan(&invite.ServerName) + if err != nil { + // Non-fatal: just leave server_name empty + invite.ServerName = "" + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(invite) +} + +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + + var invite inviteResponse + var expiresAtStr sql.NullString + var createdAtStr sql.NullString + + err := h.db.QueryRowContext(r.Context(), ` + SELECT i.id, i.code, i.server_id, COALESCE(s.name, ''), i.creator_id, + i.max_uses, i.use_count, i.expires_at::text, i.created_at::text + FROM invites i + LEFT JOIN servers s ON s.id = i.server_id + WHERE i.code = $1 + `, code).Scan( + &invite.ID, &invite.Code, &invite.ServerID, &invite.ServerName, &invite.CreatorID, + &invite.MaxUses, &invite.UseCount, &expiresAtStr, &createdAtStr, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, `{"error":"invite not found"}`, http.StatusNotFound) + } else { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + } + return + } + if expiresAtStr.Valid { + invite.ExpiresAt = &expiresAtStr.String + } + invite.CreatedAt = createdAtStr.String + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(invite) +} + +func (h *Handler) Join(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + // Fetch the invite + var inviteID, serverID string + var maxUses *int + var useCount int + var expiresAt sql.NullTime + + err := h.db.QueryRowContext(r.Context(), ` + SELECT id, server_id, max_uses, use_count, expires_at + FROM invites WHERE code = $1 + `, code).Scan(&inviteID, &serverID, &maxUses, &useCount, &expiresAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, `{"error":"invite not found"}`, http.StatusNotFound) + } else { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + } + return + } + + // Check expiration + if expiresAt.Valid && time.Now().After(expiresAt.Time) { + http.Error(w, `{"error":"invite has expired"}`, http.StatusBadRequest) + return + } + + // Check max uses + if maxUses != nil && useCount >= *maxUses { + http.Error(w, `{"error":"invite has reached maximum uses"}`, http.StatusBadRequest) + return + } + + // Check if already a member + var alreadyMember bool + err = h.db.QueryRowContext(r.Context(), ` + SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2) + `, userID, serverID).Scan(&alreadyMember) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if alreadyMember { + http.Error(w, `{"error":"already a member of this server"}`, http.StatusConflict) + return + } + + // Use a transaction to insert member and increment use_count atomically + tx, err := h.db.BeginTx(r.Context(), nil) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer tx.Rollback() + + _, err = tx.ExecContext(r.Context(), ` + INSERT INTO members (user_id, server_id) VALUES ($1, $2) + `, userID, serverID) + if err != nil { + http.Error(w, `{"error":"failed to join server"}`, http.StatusInternalServerError) + return + } + + _, err = tx.ExecContext(r.Context(), ` + UPDATE invites SET use_count = use_count + 1 WHERE id = $1 + `, inviteID) + if err != nil { + http.Error(w, `{"error":"failed to update invite"}`, http.StatusInternalServerError) + return + } + + if err := tx.Commit(); err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "server_id": serverID, + "message": "successfully joined server", + }) +} diff --git a/internal/reaction/handlers.go b/internal/reaction/handlers.go new file mode 100644 index 0000000..40d4fdd --- /dev/null +++ b/internal/reaction/handlers.go @@ -0,0 +1,247 @@ +package reaction + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +type Handler struct { + db *sql.DB + hub *gateway.Hub +} + +func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler { + return &Handler{db: db, hub: hub} +} + +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Post("/{messageID}/reactions", h.Add) + r.Delete("/{messageID}/reactions/{emoji}", h.Remove) + r.Get("/{messageID}/reactions", h.List) +} + +type addReactionRequest struct { + Emoji string `json:"emoji"` +} + +type reactionResponse struct { + ID string `json:"id"` + MessageID string `json:"message_id"` + UserID string `json:"user_id"` + Emoji string `json:"emoji"` + CreatedAt string `json:"created_at"` +} + +type emojiGroup struct { + Emoji string `json:"emoji"` + Count int `json:"count"` + Users []string `json:"users"` + Details []reactionResponse `json:"details"` +} + +// requireMessageAccess verifies the user is a member of the server that owns the message's channel. +// Returns (userID, channelID, serverID, ok). +func (h *Handler) requireMessageAccess(w http.ResponseWriter, r *http.Request, messageID string) (string, string, string, bool) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return "", "", "", false + } + + // Look up channel_id from the message + var channelID string + err := h.db.QueryRowContext(r.Context(), ` + SELECT channel_id FROM messages WHERE id = $1 + `, messageID).Scan(&channelID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, `{"error":"message not found"}`, http.StatusNotFound) + } else { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + } + return "", "", "", false + } + + // Look up server_id from the channel + var serverID string + err = h.db.QueryRowContext(r.Context(), ` + SELECT server_id FROM channels WHERE id = $1 + `, channelID).Scan(&serverID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) + } else { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + } + return "", "", "", false + } + + // Verify membership + var exists bool + err = h.db.QueryRowContext(r.Context(), ` + SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2) + `, userID, serverID).Scan(&exists) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return "", "", "", false + } + if !exists { + http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden) + return "", "", "", false + } + + return userID, channelID, serverID, true +} + +func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { + messageID := chi.URLParam(r, "messageID") + userID, channelID, _, ok := h.requireMessageAccess(w, r, messageID) + if !ok { + return + } + + var req addReactionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if req.Emoji == "" { + http.Error(w, `{"error":"emoji is required"}`, http.StatusBadRequest) + return + } + + var reaction reactionResponse + var createdAt sql.NullString + err := h.db.QueryRowContext(r.Context(), ` + INSERT INTO reactions (message_id, user_id, emoji) + VALUES ($1, $2, $3) + ON CONFLICT (message_id, user_id, emoji) DO UPDATE SET emoji = EXCLUDED.emoji + RETURNING id, message_id, user_id, emoji, created_at::text + `, messageID, userID, req.Emoji).Scan( + &reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt, + ) + if err != nil { + http.Error(w, `{"error":"failed to add reaction"}`, http.StatusInternalServerError) + return + } + reaction.CreatedAt = createdAt.String + + h.hub.BroadcastEvent(gateway.Event{ + Type: gateway.EventReactionAdd, + Data: map[string]interface{}{ + "reaction": reaction, + "channel_id": channelID, + "message_id": messageID, + }, + }) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(reaction) +} + +func (h *Handler) Remove(w http.ResponseWriter, r *http.Request) { + messageID := chi.URLParam(r, "messageID") + emoji := chi.URLParam(r, "emoji") + userID, channelID, _, ok := h.requireMessageAccess(w, r, messageID) + if !ok { + return + } + + result, err := h.db.ExecContext(r.Context(), ` + DELETE FROM reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3 + `, messageID, userID, emoji) + if err != nil { + http.Error(w, `{"error":"failed to remove reaction"}`, http.StatusInternalServerError) + return + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if rowsAffected == 0 { + http.Error(w, `{"error":"reaction not found"}`, http.StatusNotFound) + return + } + + h.hub.BroadcastEvent(gateway.Event{ + Type: gateway.EventReactionRemove, + Data: map[string]interface{}{ + "user_id": userID, + "message_id": messageID, + "channel_id": channelID, + "emoji": emoji, + }, + }) + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + messageID := chi.URLParam(r, "messageID") + if _, _, _, ok := h.requireMessageAccess(w, r, messageID); !ok { + return + } + + rows, err := h.db.QueryContext(r.Context(), ` + SELECT id, message_id, user_id, emoji, created_at::text + FROM reactions + WHERE message_id = $1 + ORDER BY created_at ASC + `, messageID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + // Group reactions by emoji + groupMap := make(map[string]*emojiGroup) + var order []string + + for rows.Next() { + var reaction reactionResponse + var createdAt sql.NullString + if err := rows.Scan( + &reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt, + ); err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + reaction.CreatedAt = createdAt.String + + group, exists := groupMap[reaction.Emoji] + if !exists { + group = &emojiGroup{ + Emoji: reaction.Emoji, + Users: []string{}, + Details: []reactionResponse{}, + } + groupMap[reaction.Emoji] = group + order = append(order, reaction.Emoji) + } + group.Count++ + group.Users = append(group.Users, reaction.UserID) + group.Details = append(group.Details, reaction) + } + if err := rows.Err(); err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + groups := make([]emojiGroup, 0, len(order)) + for _, emoji := range order { + groups = append(groups, *groupMap[emoji]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(groups) +} diff --git a/web/index.html b/web/index.html index 5e3836a..7f7423d 100644 --- a/web/index.html +++ b/web/index.html @@ -3,11 +3,25 @@
- -