282 lines
8.1 KiB
Go
282 lines
8.1 KiB
Go
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
|
|
}
|
|
|
|
// @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)
|
|
}
|