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("/", h.Add) r.Delete("/{emoji}", h.Remove) r.Get("/", 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 } // @Summary Add a reaction // @Description Add a reaction to a message // @Tags reactions // @Accept json // @Produce json // @Security SessionAuth // @Param messageID path string true "Message ID" // @Param body body addReactionRequest true "Reaction data" // @Success 201 {object} reactionResponse // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Router /messages/{messageID}/reactions [post] func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { messageID := chi.URLParam(r, "messageID") userID, channelID, serverID, 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.BroadcastToServer(serverID, 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) } // @Summary Remove a reaction // @Description Remove a reaction from a message // @Tags reactions // @Security SessionAuth // @Param messageID path string true "Message ID" // @Param emoji path string true "Emoji to remove" // @Success 204 // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /messages/{messageID}/reactions/{emoji} [delete] func (h *Handler) Remove(w http.ResponseWriter, r *http.Request) { messageID := chi.URLParam(r, "messageID") emoji := chi.URLParam(r, "emoji") userID, channelID, serverID, 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.BroadcastToServer(serverID, gateway.Event{ Type: gateway.EventReactionRemove, Data: map[string]interface{}{ "user_id": userID, "message_id": messageID, "channel_id": channelID, "emoji": emoji, }, }) w.WriteHeader(http.StatusNoContent) } // @Summary List reactions // @Description List reactions on a message grouped by emoji // @Tags reactions // @Produce json // @Security SessionAuth // @Param messageID path string true "Message ID" // @Success 200 {array} emojiGroup // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Router /messages/{messageID}/reactions [get] 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) }