Files
Messenger/server/internal/api/conversations.go
T
vantablackTea 9d03ea4c51
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s
big update: added reactions, groups, media share
2026-09-27 21:59:48 +03:00

421 lines
12 KiB
Go

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),
}
}