package message import ( "database/sql" "encoding/json" "net/http" "strconv" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) // Handler holds dependencies for message CRUD operations. type Handler struct { db *sql.DB hub *gateway.Hub } // NewHandler creates a new message Handler. func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler { return &Handler{db: db, hub: hub} } // RegisterRoutes registers message routes on the given chi.Router. // Expects to be mounted under /channels/{channelID}/messages. func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/", h.List) r.Post("/", h.Create) r.Patch("/{messageID}", h.Update) r.Delete("/{messageID}", h.Delete) } // Message is the JSON representation of a message. type Message struct { ID string `json:"id"` ChannelID string `json:"channel_id"` AuthorID string `json:"author_id"` Content string `json:"content"` EditedAt *string `json:"edited_at,omitempty"` CreatedAt string `json:"created_at"` } type createMessageRequest struct { Content string `json:"content"` } type updateMessageRequest struct { Content string `json:"content"` } // List handles GET / — returns messages in a channel with cursor-based pagination. // Query params: limit (default 50, max 100), before (message ID cursor). func (h *Handler) List(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } channelID := chi.URLParam(r, "channelID") if _, err := uuid.Parse(channelID); err != nil { writeError(w, http.StatusBadRequest, "invalid channel id") return } // Verify the user is a member of the server that owns this channel if !h.isChannelMember(r, userID, channelID) { writeError(w, http.StatusForbidden, "not a member of this server") return } // Parse pagination params limit := 50 if l := r.URL.Query().Get("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 { limit = parsed } } before := r.URL.Query().Get("before") var rows *sql.Rows var err error if before != "" { if _, err := uuid.Parse(before); err != nil { writeError(w, http.StatusBadRequest, "invalid before cursor") return } rows, err = h.db.QueryContext(r.Context(), ` SELECT id, channel_id, author_id, content, edited_at, created_at FROM messages WHERE channel_id = $1 AND created_at < (SELECT created_at FROM messages WHERE id = $2) ORDER BY created_at DESC LIMIT $3 `, channelID, before, limit) } else { rows, err = h.db.QueryContext(r.Context(), ` SELECT id, channel_id, author_id, content, edited_at, created_at FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT $2 `, channelID, limit) } if err != nil { writeError(w, http.StatusInternalServerError, "failed to list messages") return } defer rows.Close() messages := []Message{} for rows.Next() { var m Message if err := rows.Scan(&m.ID, &m.ChannelID, &m.AuthorID, &m.Content, &m.EditedAt, &m.CreatedAt); err != nil { writeError(w, http.StatusInternalServerError, "server error") return } messages = append(messages, m) } if err := rows.Err(); err != nil { writeError(w, http.StatusInternalServerError, "server error") return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(messages) } // Create handles POST / — creates a new message and broadcasts MESSAGE_CREATE. func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } channelID := chi.URLParam(r, "channelID") if _, err := uuid.Parse(channelID); err != nil { writeError(w, http.StatusBadRequest, "invalid channel id") return } if !h.isChannelMember(r, userID, channelID) { writeError(w, http.StatusForbidden, "not a member of this server") return } var req createMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request") return } if req.Content == "" { writeError(w, http.StatusBadRequest, "content is required") return } var msg Message err := h.db.QueryRowContext(r.Context(), ` INSERT INTO messages (channel_id, author_id, content) VALUES ($1, $2, $3) RETURNING id, channel_id, author_id, content, edited_at, created_at `, channelID, userID, req.Content).Scan( &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &msg.EditedAt, &msg.CreatedAt, ) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create message") return } // Broadcast MESSAGE_CREATE event via the gateway hub h.hub.BroadcastEvent(gateway.Event{ Type: gateway.EventMessageCreate, Data: msg, }) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(msg) } // Update handles PATCH /{messageID} — updates a message (author only). func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } channelID := chi.URLParam(r, "channelID") messageID := chi.URLParam(r, "messageID") if _, err := uuid.Parse(messageID); err != nil { writeError(w, http.StatusBadRequest, "invalid message id") return } var req updateMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request") return } if req.Content == "" { writeError(w, http.StatusBadRequest, "content is required") return } // Verify authorship var authorID string err := h.db.QueryRowContext(r.Context(), `SELECT author_id FROM messages WHERE id = $1 AND channel_id = $2`, messageID, channelID, ).Scan(&authorID) if err != nil { writeError(w, http.StatusNotFound, "message not found") return } if authorID != userID { writeError(w, http.StatusForbidden, "you can only edit your own messages") return } var msg Message err = h.db.QueryRowContext(r.Context(), ` UPDATE messages SET content = $1, edited_at = NOW() WHERE id = $2 AND channel_id = $3 RETURNING id, channel_id, author_id, content, edited_at, created_at `, req.Content, messageID, channelID).Scan( &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &msg.EditedAt, &msg.CreatedAt, ) if err != nil { writeError(w, http.StatusInternalServerError, "failed to update message") return } // Broadcast MESSAGE_UPDATE event h.hub.BroadcastEvent(gateway.Event{ Type: gateway.EventMessageUpdate, Data: msg, }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(msg) } // Delete handles DELETE /{messageID} — deletes a message (author only). func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } channelID := chi.URLParam(r, "channelID") messageID := chi.URLParam(r, "messageID") if _, err := uuid.Parse(messageID); err != nil { writeError(w, http.StatusBadRequest, "invalid message id") return } // Verify authorship var authorID string err := h.db.QueryRowContext(r.Context(), `SELECT author_id FROM messages WHERE id = $1 AND channel_id = $2`, messageID, channelID, ).Scan(&authorID) if err != nil { writeError(w, http.StatusNotFound, "message not found") return } if authorID != userID { writeError(w, http.StatusForbidden, "you can only delete your own messages") return } result, err := h.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id = $1 AND channel_id = $2`, messageID, channelID, ) if err != nil { writeError(w, http.StatusInternalServerError, "failed to delete message") return } rows, _ := result.RowsAffected() if rows == 0 { writeError(w, http.StatusNotFound, "message not found") return } // Broadcast MESSAGE_DELETE event h.hub.BroadcastEvent(gateway.Event{ Type: gateway.EventMessageDelete, Data: map[string]string{ "id": messageID, "channel_id": channelID, }, }) w.WriteHeader(http.StatusNoContent) } // isChannelMember checks if a user is a member of the server that owns the channel. func (h *Handler) isChannelMember(r *http.Request, userID, channelID string) bool { var exists bool h.db.QueryRowContext(r.Context(), ` SELECT EXISTS( SELECT 1 FROM members m INNER JOIN channels c ON c.server_id = m.server_id WHERE m.user_id = $1 AND c.id = $2 ) `, userID, channelID).Scan(&exists) return exists } // writeError sends a JSON error response. func writeError(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{"error": message}) }