package message import ( "context" "database/sql" "encoding/json" "errors" "net/http" "strconv" "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.Get("/{channelID}/messages", h.List) r.Post("/{channelID}/messages", h.Create) r.Patch("/{messageID}", h.Update) r.Delete("/{messageID}", h.Delete) } type messageResponse struct { ID string `json:"id"` ChannelID string `json:"channel_id"` AuthorID string `json:"author_id"` AuthorName string `json:"author_username"` DisplayName *string `json:"author_display_name"` Content string `json:"content"` EditedAt *string `json:"edited_at"` CreatedAt string `json:"created_at"` } // isMember checks whether the given user is a member of the given server. func (h *Handler) isMember(ctx context.Context, userID, serverID string) (bool, error) { var exists bool err := h.db.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2) `, userID, serverID).Scan(&exists) return exists, err } // serverIDForChannel returns the server_id that owns the given channel. func (h *Handler) serverIDForChannel(ctx context.Context, channelID string) (string, error) { var serverID string err := h.db.QueryRowContext(ctx, ` SELECT server_id FROM channels WHERE id = $1 `, channelID).Scan(&serverID) return serverID, err } // requireChannelAccess verifies the user is a member of the server owning the channel, // returning the server_id if successful. func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, bool) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return "", false } serverID, err := h.serverIDForChannel(r.Context(), channelID) 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 } member, err := h.isMember(r.Context(), userID, serverID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return "", false } if !member { http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden) return "", false } return userID, true } func (h *Handler) List(w http.ResponseWriter, r *http.Request) { channelID := chi.URLParam(r, "channelID") if _, ok := h.requireChannelAccess(w, r, channelID); !ok { return } // Parse query 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 != "" { // Fetch the created_at of the cursor message var cursorTime sql.NullString err = h.db.QueryRowContext(r.Context(), ` SELECT created_at::text FROM messages WHERE id = $1 `, before).Scan(&cursorTime) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, `{"error":"cursor message not found"}`, http.StatusBadRequest) return } http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } rows, err = h.db.QueryContext(r.Context(), ` SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text FROM messages m INNER JOIN users u ON u.id = m.author_id WHERE m.channel_id = $1 AND m.created_at < $2::timestamptz ORDER BY m.created_at DESC LIMIT $3 `, channelID, cursorTime.String, limit) } else { rows, err = h.db.QueryContext(r.Context(), ` SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text FROM messages m INNER JOIN users u ON u.id = m.author_id WHERE m.channel_id = $1 ORDER BY m.created_at DESC LIMIT $2 `, channelID, limit) } if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } defer rows.Close() messages := make([]messageResponse, 0) for rows.Next() { var msg messageResponse var editedAt, createdAt sql.NullString if err := rows.Scan( &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &editedAt, &createdAt, ); err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if editedAt.Valid { msg.EditedAt = &editedAt.String } msg.CreatedAt = createdAt.String messages = append(messages, msg) } if err := rows.Err(); err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(messages) } type createMessageRequest struct { Content string `json:"content"` } func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { channelID := chi.URLParam(r, "channelID") userID, ok := h.requireChannelAccess(w, r, channelID) if !ok { return } var req createMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if req.Content == "" { http.Error(w, `{"error":"content is required"}`, http.StatusBadRequest) return } var msg messageResponse var editedAt sql.NullString var createdAt sql.NullString 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::text, created_at::text `, channelID, userID, req.Content).Scan( &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt, ) if err != nil { http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError) return } // Fetch author info err = h.db.QueryRowContext(r.Context(), ` SELECT username, display_name FROM users WHERE id = $1 `, userID).Scan(&msg.AuthorName, &msg.DisplayName) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if editedAt.Valid { msg.EditedAt = &editedAt.String } msg.CreatedAt = createdAt.String // Broadcast MESSAGE_CREATE event via WebSocket 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) } type updateMessageRequest struct { Content string `json:"content"` } func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } messageID := chi.URLParam(r, "messageID") var req updateMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if req.Content == "" { http.Error(w, `{"error":"content is required"}`, http.StatusBadRequest) return } // Verify authorship var authorID string err := h.db.QueryRowContext(r.Context(), ` SELECT author_id FROM messages WHERE id = $1 `, messageID).Scan(&authorID) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, `{"error":"message not found"}`, http.StatusNotFound) return } http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if authorID != userID { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } var msg messageResponse var editedAt sql.NullString var createdAt sql.NullString err = h.db.QueryRowContext(r.Context(), ` UPDATE messages SET content = $1, edited_at = NOW() WHERE id = $2 RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text `, req.Content, messageID).Scan( &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt, ) if err != nil { http.Error(w, `{"error":"failed to update message"}`, http.StatusInternalServerError) return } // Fetch author info err = h.db.QueryRowContext(r.Context(), ` SELECT username, display_name FROM users WHERE id = $1 `, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if editedAt.Valid { msg.EditedAt = &editedAt.String } msg.CreatedAt = createdAt.String // Broadcast MESSAGE_UPDATE event via WebSocket h.hub.BroadcastEvent(gateway.Event{ Type: gateway.EventMessageUpdate, Data: msg, }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(msg) } func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } messageID := chi.URLParam(r, "messageID") // Verify authorship and get channel_id for the broadcast var authorID, channelID string err := h.db.QueryRowContext(r.Context(), ` SELECT author_id, channel_id FROM messages WHERE id = $1 `, messageID).Scan(&authorID, &channelID) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, `{"error":"message not found"}`, http.StatusNotFound) return } http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if authorID != userID { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } _, err = h.db.ExecContext(r.Context(), ` DELETE FROM messages WHERE id = $1 `, messageID) if err != nil { http.Error(w, `{"error":"failed to delete message"}`, http.StatusInternalServerError) return } // Broadcast MESSAGE_DELETE event via WebSocket h.hub.BroadcastEvent(gateway.Event{ Type: gateway.EventMessageDelete, Data: map[string]string{ "id": messageID, "channel_id": channelID, }, }) w.WriteHeader(http.StatusNoContent) }