package api import ( "encoding/json" "net/http" "strconv" "strings" "time" "messenger/internal/realtime" "messenger/internal/store" ) type conversationJSON struct { ID int64 `json:"id"` Type string `json:"type"` Name string `json:"name"` Avatar string `json:"avatar"` CreatedAt time.Time `json:"created_at"` LastMessage *messageJSON `json:"last_message,omitempty"` UnreadCount int64 `json:"unread_count"` } type memberJSON struct { User userJSON `json:"user"` Role string `json:"role"` JoinedAt time.Time `json:"joined_at"` } type messageJSON struct { ID int64 `json:"id"` ConversationID int64 `json:"conversation_id"` SenderID int64 `json:"sender_id"` Body string `json:"body"` Reactions []reactionJSON `json:"reactions"` Media *mediaJSON `json:"media,omitempty"` CreatedAt time.Time `json:"created_at"` EditedAt *time.Time `json:"edited_at,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"` } type reactionJSON struct { Emoji string `json:"emoji"` UserID int64 `json:"user_id"` } func toMessageJSON(m *store.Message) messageJSON { return messageJSON{ ID: m.ID, ConversationID: m.ConversationID, SenderID: m.SenderID, Body: m.Body, Reactions: []reactionJSON{}, CreatedAt: m.CreatedAt, EditedAt: m.EditedAt, DeletedAt: m.DeletedAt, } } func attachReactions(msgs []messageJSON, reactions []store.Reaction) { byMessage := make(map[int64][]reactionJSON) for _, r := range reactions { byMessage[r.MessageID] = append(byMessage[r.MessageID], reactionJSON{Emoji: r.Emoji, UserID: r.UserID}) } for i := range msgs { if rs, ok := byMessage[msgs[i].ID]; ok { msgs[i].Reactions = rs } } } func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) { userID, _ := userIDFrom(r.Context()) summaries, err := h.store.ListConversationsForUser(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, "internal server error") return } out := make([]conversationJSON, 0, len(summaries)) for _, s := range summaries { item := conversationJSON{ ID: s.ID, Type: s.Type, Name: s.Name, Avatar: s.Avatar, CreatedAt: s.CreatedAt, UnreadCount: s.UnreadCount, } if s.LastMessage != nil { msg := toMessageJSON(s.LastMessage) item.LastMessage = &msg } out = append(out, item) } writeJSON(w, http.StatusOK, out) } type createConversationRequest struct { Type string `json:"type"` UserID int64 `json:"user_id"` Name string `json:"name"` MemberIDs []int64 `json:"member_ids"` } func (h *apiHandler) createConversation(w http.ResponseWriter, r *http.Request) { userID, _ := userIDFrom(r.Context()) var req createConversationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } if req.Type == "group" || req.Name != "" { h.createGroup(w, r, userID, req) return } if req.UserID <= 0 { writeError(w, http.StatusBadRequest, "invalid user_id") return } if req.UserID == userID { writeError(w, http.StatusBadRequest, "cannot chat with yourself") return } conv, err := h.store.CreateDM(r.Context(), userID, req.UserID) if err != nil { writeError(w, http.StatusInternalServerError, "internal server error") return } writeJSON(w, http.StatusCreated, conversationJSON{ ID: conv.ID, Type: conv.Type, Name: conv.Name, Avatar: conv.Avatar, CreatedAt: conv.CreatedAt, }) } func (h *apiHandler) createGroup(w http.ResponseWriter, r *http.Request, userID int64, req createConversationRequest) { name := strings.TrimSpace(req.Name) if name == "" || len(name) > 64 { writeError(w, http.StatusBadRequest, "name must be 1-64 chars") return } for _, uid := range req.MemberIDs { if uid == userID { continue } if _, err := h.store.GetUserByID(r.Context(), uid); err != nil { writeError(w, http.StatusBadRequest, "invalid member_id") return } } conv, err := h.store.CreateGroup(r.Context(), userID, name, req.MemberIDs) if err != nil { writeError(w, http.StatusInternalServerError, "internal server error") return } h.hub.Subscribe(userID, conv.ID) for _, uid := range req.MemberIDs { if uid == userID { continue } h.hub.Subscribe(uid, conv.ID) h.hub.SendToUser(uid, realtime.EventConversationAdded, realtime.ConversationChanged{ConversationID: conv.ID}) } writeJSON(w, http.StatusCreated, conversationJSON{ ID: conv.ID, Type: conv.Type, Name: conv.Name, Avatar: conv.Avatar, CreatedAt: conv.CreatedAt, }) } func (h *apiHandler) listMembers(w http.ResponseWriter, r *http.Request) { userID, _ := userIDFrom(r.Context()) convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || convID <= 0 { writeError(w, http.StatusBadRequest, "invalid conversation id") return } if member, err := h.store.IsMember(r.Context(), convID, userID); err != nil || !member { writeError(w, http.StatusForbidden, "not a member") return } members, err := h.store.ListMembers(r.Context(), convID) if err != nil { writeError(w, http.StatusInternalServerError, "internal server error") return } out := make([]memberJSON, 0, len(members)) for _, m := range members { out = append(out, memberJSON{User: toUserJSON(&m.User), Role: m.Role, JoinedAt: m.JoinedAt}) } writeJSON(w, http.StatusOK, out) } func (h *apiHandler) addMember(w http.ResponseWriter, r *http.Request) { userID, _ := userIDFrom(r.Context()) convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || convID <= 0 { writeError(w, http.StatusBadRequest, "invalid conversation id") return } if member, err := h.store.IsMember(r.Context(), convID, userID); err != nil || !member { writeError(w, http.StatusForbidden, "not a member") return } conv, err := h.store.GetConversationByID(r.Context(), convID) if err != nil { writeError(w, http.StatusNotFound, "conversation not found") return } if conv.Type != "group" { writeError(w, http.StatusBadRequest, "only groups support members") return } var req struct { UserID int64 `json:"user_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 { writeError(w, http.StatusBadRequest, "invalid user_id") return } if _, err := h.store.GetUserByID(r.Context(), req.UserID); err != nil { writeError(w, http.StatusNotFound, "user not found") return } if err := h.store.AddMember(r.Context(), convID, req.UserID); err != nil { writeError(w, http.StatusInternalServerError, "internal server error") return } h.hub.Subscribe(req.UserID, convID) h.hub.SendToUser(req.UserID, realtime.EventConversationAdded, realtime.ConversationChanged{ConversationID: convID}) h.hub.PublishMemberChanged(convID, req.UserID, true) writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"}) } func (h *apiHandler) removeMember(w http.ResponseWriter, r *http.Request) { userID, _ := userIDFrom(r.Context()) convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || convID <= 0 { writeError(w, http.StatusBadRequest, "invalid conversation id") return } targetID, err := strconv.ParseInt(r.PathValue("user_id"), 10, 64) if err != nil || targetID <= 0 { writeError(w, http.StatusBadRequest, "invalid user_id") return } requesterRole, err := h.store.MemberRole(r.Context(), convID, userID) if err != nil { writeError(w, http.StatusForbidden, "not a member") return } targetRole, err := h.store.MemberRole(r.Context(), convID, targetID) if err != nil { writeError(w, http.StatusNotFound, "member not found") return } if targetRole == "owner" { writeError(w, http.StatusBadRequest, "cannot remove the owner") return } if userID != targetID && requesterRole != "owner" { writeError(w, http.StatusForbidden, "only the owner can remove others") return } if err := h.store.RemoveMember(r.Context(), convID, targetID); err != nil { writeError(w, http.StatusInternalServerError, "internal server error") return } h.hub.Unsubscribe(targetID, convID) h.hub.SendToUser(targetID, realtime.EventConversationRemoved, realtime.ConversationChanged{ConversationID: convID}) h.hub.PublishMemberChanged(convID, targetID, false) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (h *apiHandler) listMessages(w http.ResponseWriter, r *http.Request) { userID, _ := userIDFrom(r.Context()) convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || convID <= 0 { writeError(w, http.StatusBadRequest, "invalid conversation id") return } member, err := h.store.IsMember(r.Context(), convID, userID) if err != nil || !member { writeError(w, http.StatusForbidden, "not a member") return } limit := 50 if v := r.URL.Query().Get("limit"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 { limit = n } } var before int64 if v := r.URL.Query().Get("before"); v != "" { before, _ = strconv.ParseInt(v, 10, 64) } messages, err := h.store.ListMessages(r.Context(), convID, before, limit) if err != nil { writeError(w, http.StatusInternalServerError, "internal server error") return } out := make([]messageJSON, 0, len(messages)) msgIDs := make([]int64, 0, len(messages)) mediaIDs := make(map[int64]struct{}) for i := range messages { out = append(out, toMessageJSON(&messages[i])) msgIDs = append(msgIDs, messages[i].ID) if messages[i].MediaID != nil { mediaIDs[*messages[i].MediaID] = struct{}{} } } reactions, err := h.store.ReactionsForMessages(r.Context(), msgIDs) if err != nil { writeError(w, http.StatusInternalServerError, "internal server error") return } attachReactions(out, reactions) mediaByID := make(map[int64]mediaJSON) for id := range mediaIDs { m, err := h.store.GetMediaByID(r.Context(), id) if err == nil { mediaByID[id] = toMediaJSON(m) } } for i := range out { for j := range messages { if messages[j].ID == out[i].ID && messages[j].MediaID != nil { if md, ok := mediaByID[*messages[j].MediaID]; ok { out[i].Media = &md } break } } } writeJSON(w, http.StatusOK, out) } type sendMessageRequest struct { Body string `json:"body"` TempID string `json:"temp_id"` MediaID *int64 `json:"media_id,omitempty"` } func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) { userID, _ := userIDFrom(r.Context()) convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || convID <= 0 { writeError(w, http.StatusBadRequest, "invalid conversation id") return } member, err := h.store.IsMember(r.Context(), convID, userID) if err != nil || !member { writeError(w, http.StatusForbidden, "not a member") return } var req sendMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } if req.Body == "" && req.MediaID == nil { writeError(w, http.StatusBadRequest, "body or media_id is required") return } if len(req.Body) > 4000 { writeError(w, http.StatusBadRequest, "body too long (max 4000)") return } var attached *store.Media if req.MediaID != nil { attached, err = h.store.GetMediaByID(r.Context(), *req.MediaID) if err != nil || attached.UploaderID != userID { writeError(w, http.StatusBadRequest, "invalid media_id") return } } msg, err := h.store.InsertMessage(r.Context(), convID, userID, req.Body, req.MediaID) if err != nil { writeError(w, http.StatusInternalServerError, "internal server error") return } wsEvent := realtime.MessageCreated{ TempID: req.TempID, ConversationID: convID, MessageID: msg.ID, SenderID: userID, Body: msg.Body, CreatedAt: msg.CreatedAt, } if attached != nil { wsEvent.MediaID = &attached.ID info := toRealtimeMedia(attached) wsEvent.Media = &info } h.hub.PublishMessageCreated(convID, wsEvent) out := toMessageJSON(msg) if attached != nil { md := toMediaJSON(attached) out.Media = &md } writeJSON(w, http.StatusCreated, out) } func toRealtimeMedia(m *store.Media) realtime.MediaInfo { return realtime.MediaInfo{ ID: m.ID, Filename: m.Filename, ContentType: m.ContentType, Size: m.Size, URL: "/api/v1/media/" + strconv.FormatInt(m.ID, 10), } }