From ec5f0a11468ab0af641f5e4ddb963c48917dc1cf Mon Sep 17 00:00:00 2001 From: Joan Manuel Jaramillo Avila <89425013+LifeRIP@users.noreply.github.com> Date: Tue, 10 Jun 2025 19:20:10 -0500 Subject: [PATCH 01/12] fix(docs): Update message retrieval routes to reflect pagination changes --- docs/docs.go | 71 ++++++++++++++++++++++++++++++- docs/swagger.json | 71 ++++++++++++++++++++++++++++++- docs/swagger.yaml | 48 ++++++++++++++++++++- internal/handlers/chat_handler.go | 4 +- 4 files changed, 189 insertions(+), 5 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 0d99461..3f0dddf 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -147,7 +147,7 @@ const docTemplate = `{ } } }, - "/chat/direct/{chatId}/messages/paginated": { + "/chat/direct/{chatId}/messages": { "get": { "security": [ { @@ -595,6 +595,75 @@ const docTemplate = `{ } } }, + "/chat/rooms/{roomId}/messages/paginated": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Devuelve los mensajes de una sala específica con soporte para paginación ordernada por fecha de creación descendente", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "Obtiene mensajes de una sala", + "parameters": [ + { + "type": "string", + "description": "ID de la sala", + "name": "roomId", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 50, + "description": "Límite de mensajes a obtener", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "default": "\"1747441934\"", + "description": "Cursor para paginación (timestamp)", + "name": "cursor", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Mensajes paginados de la sala", + "schema": { + "$ref": "#/definitions/models.PaginatedMessagesResponse" + } + }, + "401": { + "description": "No autorizado", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Sala no encontrada", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Error interno del servidor", + "schema": { + "type": "string" + } + } + } + } + }, "/chat/ws": { "get": { "description": "Establece una conexión WebSocket para mensajería en tiempo real", diff --git a/docs/swagger.json b/docs/swagger.json index dc8cd53..e9767cf 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -140,7 +140,7 @@ } } }, - "/chat/direct/{chatId}/messages/paginated": { + "/chat/direct/{chatId}/messages": { "get": { "security": [ { @@ -588,6 +588,75 @@ } } }, + "/chat/rooms/{roomId}/messages/paginated": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Devuelve los mensajes de una sala específica con soporte para paginación ordernada por fecha de creación descendente", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "Obtiene mensajes de una sala", + "parameters": [ + { + "type": "string", + "description": "ID de la sala", + "name": "roomId", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 50, + "description": "Límite de mensajes a obtener", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "default": "\"1747441934\"", + "description": "Cursor para paginación (timestamp)", + "name": "cursor", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Mensajes paginados de la sala", + "schema": { + "$ref": "#/definitions/models.PaginatedMessagesResponse" + } + }, + "401": { + "description": "No autorizado", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Sala no encontrada", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Error interno del servidor", + "schema": { + "type": "string" + } + } + } + } + }, "/chat/ws": { "get": { "description": "Establece una conexión WebSocket para mensajería en tiempo real", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e34ac6a..15ae2f0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -206,7 +206,7 @@ paths: summary: Registra un nuevo usuario tags: - Auth - /chat/direct/{chatId}/messages/paginated: + /chat/direct/{chatId}/messages: get: consumes: - application/json @@ -493,6 +493,52 @@ paths: summary: Obtiene mensajes de una sala (versión simple) tags: - Chat + /chat/rooms/{roomId}/messages/paginated: + get: + consumes: + - application/json + description: Devuelve los mensajes de una sala específica con soporte para paginación + ordernada por fecha de creación descendente + parameters: + - description: ID de la sala + in: path + name: roomId + required: true + type: string + - default: 50 + description: Límite de mensajes a obtener + in: query + name: limit + type: integer + - default: '"1747441934"' + description: Cursor para paginación (timestamp) + in: query + name: cursor + type: string + produces: + - application/json + responses: + "200": + description: Mensajes paginados de la sala + schema: + $ref: '#/definitions/models.PaginatedMessagesResponse' + "401": + description: No autorizado + schema: + type: string + "404": + description: Sala no encontrada + schema: + type: string + "500": + description: Error interno del servidor + schema: + type: string + security: + - BearerAuth: [] + summary: Obtiene mensajes de una sala + tags: + - Chat /chat/rooms/me: get: consumes: diff --git a/internal/handlers/chat_handler.go b/internal/handlers/chat_handler.go index c111ba5..6a04aff 100644 --- a/internal/handlers/chat_handler.go +++ b/internal/handlers/chat_handler.go @@ -134,7 +134,7 @@ func (h *ChatHandler) GetUserRooms(w http.ResponseWriter, r *http.Request) { // @Failure 401 {string} string "No autorizado" // @Failure 404 {string} string "Sala no encontrada" // @Failure 500 {string} string "Error interno del servidor" -// @Router /chat/rooms/{roomId}/messages [get] +// @Router /chat/rooms/{roomId}/messages/paginated [get] func (h *ChatHandler) GetRoomMessages(w http.ResponseWriter, r *http.Request) { roomID := chi.URLParam(r, "roomId") @@ -247,7 +247,7 @@ func (h *ChatHandler) GetUserDirectChats(w http.ResponseWriter, r *http.Request) // @Failure 401 {string} string "No autorizado" // @Failure 404 {string} string "Chat no encontrado" // @Failure 500 {string} string "Error interno del servidor" -// @Router /chat/direct/{chatId}/messages/paginated [get] +// @Router /chat/direct/{chatId}/messages [get] func (h *ChatHandler) GetDirectChatMessages(w http.ResponseWriter, r *http.Request) { chatID := chi.URLParam(r, "chatId") From 411ce04fbfc4ce3cb3662cdac305ce492e3dce39 Mon Sep 17 00:00:00 2001 From: Joan Manuel Jaramillo Avila <89425013+LifeRIP@users.noreply.github.com> Date: Tue, 10 Jun 2025 19:53:44 -0500 Subject: [PATCH 02/12] feat: Add endpoint to retrieve direct chat details by ID - Introduces a new API endpoint to fetch direct chat details by ID. - Includes validation for user access rights to ensure authorized retrieval. - Updates routing to register the new endpoint. --- docs/docs.go | 55 +++++++++++++++++++++++++++++++ docs/swagger.json | 55 +++++++++++++++++++++++++++++++ docs/swagger.yaml | 35 ++++++++++++++++++++ internal/handlers/chat_handler.go | 47 ++++++++++++++++++++++++++ internal/routes/router.go | 1 + 5 files changed, 193 insertions(+) diff --git a/docs/docs.go b/docs/docs.go index 3f0dddf..ed790b8 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -147,6 +147,61 @@ const docTemplate = `{ } } }, + "/chat/direct/{chatId}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Devuelve los detalles de un chat directo específico", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "Obtiene un chat directo por ID", + "parameters": [ + { + "type": "string", + "description": "ID del chat directo", + "name": "chatId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Detalles del chat directo", + "schema": { + "$ref": "#/definitions/models.DirectChat" + } + }, + "401": { + "description": "No autorizado", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Chat no encontrado", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Error interno del servidor", + "schema": { + "type": "string" + } + } + } + } + }, "/chat/direct/{chatId}/messages": { "get": { "security": [ diff --git a/docs/swagger.json b/docs/swagger.json index e9767cf..7e57785 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -140,6 +140,61 @@ } } }, + "/chat/direct/{chatId}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Devuelve los detalles de un chat directo específico", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "Obtiene un chat directo por ID", + "parameters": [ + { + "type": "string", + "description": "ID del chat directo", + "name": "chatId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Detalles del chat directo", + "schema": { + "$ref": "#/definitions/models.DirectChat" + } + }, + "401": { + "description": "No autorizado", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Chat no encontrado", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Error interno del servidor", + "schema": { + "type": "string" + } + } + } + } + }, "/chat/direct/{chatId}/messages": { "get": { "security": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 15ae2f0..ecf85a5 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -206,6 +206,41 @@ paths: summary: Registra un nuevo usuario tags: - Auth + /chat/direct/{chatId}: + get: + consumes: + - application/json + description: Devuelve los detalles de un chat directo específico + parameters: + - description: ID del chat directo + in: path + name: chatId + required: true + type: string + produces: + - application/json + responses: + "200": + description: Detalles del chat directo + schema: + $ref: '#/definitions/models.DirectChat' + "401": + description: No autorizado + schema: + type: string + "404": + description: Chat no encontrado + schema: + type: string + "500": + description: Error interno del servidor + schema: + type: string + security: + - BearerAuth: [] + summary: Obtiene un chat directo por ID + tags: + - Chat /chat/direct/{chatId}/messages: get: consumes: diff --git a/internal/handlers/chat_handler.go b/internal/handlers/chat_handler.go index 6a04aff..ce66707 100644 --- a/internal/handlers/chat_handler.go +++ b/internal/handlers/chat_handler.go @@ -376,3 +376,50 @@ func (h *ChatHandler) GetRoomMessagesSimple(w http.ResponseWriter, r *http.Reque json.NewEncoder(w).Encode(messages) } + +// GetChat obtiene un chat directo por ID +// +// @Summary Obtiene un chat directo por ID +// @Description Devuelve los detalles de un chat directo específico +// @Tags Chat +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param chatId path string true "ID del chat directo" +// @Success 200 {object} models.DirectChat "Detalles del chat directo" +// @Failure 401 {string} string "No autorizado" +// @Failure 404 {string} string "Chat no encontrado" +// @Failure 500 {string} string "Error interno del servidor" +// @Router /chat/direct/{chatId} [get] +func (h *ChatHandler) GetChat(w http.ResponseWriter, r *http.Request) { + chatID := chi.URLParam(r, "chatId") + + // Obtener el ID del usuario del contexto + userID, ok := r.Context().Value("userID").(string) + if !ok { + http.Error(w, "User ID not found in context", http.StatusInternalServerError) + return + } + + chat, err := h.DirectChatService.GetDirectChat(chatID) + if err != nil { + http.Error(w, "Error getting chat: "+err.Error(), http.StatusInternalServerError) + return + } + + // Verificar si el usuario tiene acceso a este chat + hasAccess := false + for _, id := range chat.UserIDs { + if id == userID { + hasAccess = true + break + } + } + + if !hasAccess { + http.Error(w, "Unauthorized access to this chat", http.StatusForbidden) + return + } + + json.NewEncoder(w).Encode(chat) +} diff --git a/internal/routes/router.go b/internal/routes/router.go index 147b58d..90021b0 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -77,6 +77,7 @@ func NewRouter( r.Route("/direct", func(r chi.Router) { r.Post("/{otherUserId}", chatHandler.CreateDirectChat) r.Get("/me", chatHandler.GetUserDirectChats) + r.Get("/{chatId}", chatHandler.GetChat) r.Get("/{chatId}/messages", chatHandler.GetDirectChatMessages) }) }) From 046d3df150f1b488fc306d5be795b71786871978 Mon Sep 17 00:00:00 2001 From: Joan Manuel Jaramillo Avila <89425013+LifeRIP@users.noreply.github.com> Date: Tue, 10 Jun 2025 22:24:23 -0500 Subject: [PATCH 03/12] feat: Adds sender display names to direct chat messages - Enhances direct chat functionality by including sender display names in responses. - Updates service methods to fetch and attach sender names to the last message in direct chats. - Introduces corresponding models changes to support optional display name serialization. - Improves user experience by providing more informative chat details. --- internal/handlers/chat_handler.go | 4 +- internal/models/message.go | 15 ++-- internal/services/directchat_service.go | 91 +++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/internal/handlers/chat_handler.go b/internal/handlers/chat_handler.go index ce66707..24dd743 100644 --- a/internal/handlers/chat_handler.go +++ b/internal/handlers/chat_handler.go @@ -224,7 +224,7 @@ func (h *ChatHandler) GetUserDirectChats(w http.ResponseWriter, r *http.Request) return } - chats, err := h.DirectChatService.GetUserDirectChats(userID) + chats, err := h.DirectChatService.GetUserDirectChatsWithSenderNames(userID) if err != nil { http.Error(w, "Error getting direct chats: "+err.Error(), http.StatusInternalServerError) return @@ -401,7 +401,7 @@ func (h *ChatHandler) GetChat(w http.ResponseWriter, r *http.Request) { return } - chat, err := h.DirectChatService.GetDirectChat(chatID) + chat, err := h.DirectChatService.GetDirectChatWithSenderName(chatID) if err != nil { http.Error(w, "Error getting chat: "+err.Error(), http.StatusInternalServerError) return diff --git a/internal/models/message.go b/internal/models/message.go index bca0e73..0b0e9ef 100644 --- a/internal/models/message.go +++ b/internal/models/message.go @@ -4,13 +4,14 @@ import "time" // Message representa un mensaje enviado por un usuario type Message struct { - ID string `json:"id" firestore:"id"` - Content string `json:"content" firestore:"content"` - UserID string `json:"userId" firestore:"userId"` - RoomID string `json:"roomId" firestore:"roomId"` - CreatedAt time.Time `json:"createdAt" firestore:"createdAt"` - UpdatedAt time.Time `json:"updatedAt" firestore:"updatedAt"` - IsDeleted bool `json:"isDeleted" firestore:"isDeleted"` + ID string `json:"id" firestore:"id"` + Content string `json:"content" firestore:"content"` + UserID string `json:"userId" firestore:"userId"` + RoomID string `json:"roomId" firestore:"roomId"` + CreatedAt time.Time `json:"createdAt" firestore:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" firestore:"updatedAt"` + IsDeleted bool `json:"isDeleted" firestore:"isDeleted"` + DisplayName string `json:"displayName,omitempty" firestore:"-"` // Excluido de Firestore } // MessageResponse es la respuesta que incluye un mensaje y el nombre del usuario que lo envió diff --git a/internal/services/directchat_service.go b/internal/services/directchat_service.go index a6f5567..c6006eb 100644 --- a/internal/services/directchat_service.go +++ b/internal/services/directchat_service.go @@ -1,6 +1,8 @@ package services import ( + "context" + "github.com/Parchat/backend/internal/models" "github.com/Parchat/backend/internal/repositories" ) @@ -34,6 +36,37 @@ func (s *DirectChatService) GetUserDirectChats(userID string) ([]models.DirectCh return s.DirectChatRepo.GetUserDirectChats(userID) } +// GetUserDirectChatsWithSenderNames obtiene todos los chats directos con nombres de remitentes +func (s *DirectChatService) GetUserDirectChatsWithSenderNames(userID string) ([]models.DirectChat, error) { + chats, err := s.DirectChatRepo.GetUserDirectChats(userID) + if err != nil { + return nil, err + } + + ctx := context.Background() + client := s.DirectChatRepo.FirestoreClient.Client + + // Para cada chat, obtener el nombre del remitente del último mensaje + for i := range chats { + if chats[i].LastMessage != nil && chats[i].LastMessage.UserID != "" { + userDoc, err := client.Collection("users").Doc(chats[i].LastMessage.UserID).Get(ctx) + if err == nil { + var user models.User + if err := userDoc.DataTo(&user); err == nil { + // Crear una copia del mensaje para añadir el displayName + messageCopy := *chats[i].LastMessage + // Agregar el displayName a la estructura Message (se serializa como JSON aunque no esté en la estructura) + messageCopy.DisplayName = user.DisplayName + // Reemplazar el mensaje original con la copia que incluye displayName + chats[i].LastMessage = &messageCopy + } + } + } + } + + return chats, nil +} + // GetDirectChatMessages obtiene los mensajes de un chat directo func (s *DirectChatService) GetDirectChatMessages(directChatID string, limit int) ([]models.MessageResponse, error) { return s.MessageRepo.GetDirectChatMessages(directChatID, limit) @@ -43,3 +76,61 @@ func (s *DirectChatService) GetDirectChatMessages(directChatID string, limit int func (s *DirectChatService) FindOrCreateDirectChat(userID1, userID2 string) (*models.DirectChat, error) { return s.DirectChatRepo.FindOrCreateDirectChat(userID1, userID2) } + +// FindOrCreateDirectChatWithSenderName encuentra o crea un chat directo e incluye el nombre del remitente +func (s *DirectChatService) FindOrCreateDirectChatWithSenderName(userID1, userID2 string) (*models.DirectChat, error) { + chat, err := s.DirectChatRepo.FindOrCreateDirectChat(userID1, userID2) + if err != nil { + return nil, err + } + + // Añadir el displayName al último mensaje si existe + if chat.LastMessage != nil && chat.LastMessage.UserID != "" { + ctx := context.Background() + client := s.DirectChatRepo.FirestoreClient.Client + + userDoc, err := client.Collection("users").Doc(chat.LastMessage.UserID).Get(ctx) + if err == nil { + var user models.User + if err := userDoc.DataTo(&user); err == nil { + // Crear una copia del mensaje para añadir el displayName + messageCopy := *chat.LastMessage + // Agregar el displayName a la estructura Message + messageCopy.DisplayName = user.DisplayName + // Reemplazar el mensaje original con la copia + chat.LastMessage = &messageCopy + } + } + } + + return chat, nil +} + +// GetDirectChatWithSenderName obtiene un chat directo por su ID e incluye el nombre del remitente del último mensaje +func (s *DirectChatService) GetDirectChatWithSenderName(directChatID string) (*models.DirectChat, error) { + chat, err := s.DirectChatRepo.GetDirectChat(directChatID) + if err != nil { + return nil, err + } + + // Añadir el displayName al último mensaje si existe + if chat.LastMessage != nil && chat.LastMessage.UserID != "" { + ctx := context.Background() + client := s.DirectChatRepo.FirestoreClient.Client + + userDoc, err := client.Collection("users").Doc(chat.LastMessage.UserID).Get(ctx) + if err == nil { + var user models.User + if err := userDoc.DataTo(&user); err == nil { + // Crear una copia del mensaje para añadir el displayName + messageCopy := *chat.LastMessage + // Agregar el displayName a la estructura Message + messageCopy.DisplayName = user.DisplayName + // Reemplazar el mensaje original con la copia + chat.LastMessage = &messageCopy + } + } + } + + return chat, nil +} From 2a9c7d490a2cde29afdf12d1578bcdb72e2ce723 Mon Sep 17 00:00:00 2001 From: Joan Manuel Jaramillo Avila <89425013+LifeRIP@users.noreply.github.com> Date: Tue, 10 Jun 2025 23:02:20 -0500 Subject: [PATCH 04/12] feat: Adds Firestore client integration to WebSocket hub - Integrates Firestore client into the WebSocket hub and client logic to retrieve user display names during message processing. - Enhances functionality by enabling dynamic user data fetching from Firestore, improving message context for chat interactions. --- internal/pkg/websocket/hub.go | 6 ++++++ internal/pkg/websocket/websocket.go | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/internal/pkg/websocket/hub.go b/internal/pkg/websocket/hub.go index c0c761f..deee21e 100644 --- a/internal/pkg/websocket/hub.go +++ b/internal/pkg/websocket/hub.go @@ -1,6 +1,7 @@ package websocket import ( + "github.com/Parchat/backend/internal/config" "github.com/Parchat/backend/internal/repositories" ) @@ -32,6 +33,9 @@ type Hub struct { messageRepo *repositories.MessageRepository roomRepo *repositories.RoomRepository directChatRepo *repositories.DirectChatRepository + + // Firestore client + firestoreClient *config.FirestoreClient } // NewHub inicializa un nuevo Hub @@ -39,6 +43,7 @@ func NewHub( messageRepo *repositories.MessageRepository, roomRepo *repositories.RoomRepository, directChatRepo *repositories.DirectChatRepository, + client *config.FirestoreClient, ) *Hub { return &Hub{ clients: make(map[*Client]bool), @@ -49,6 +54,7 @@ func NewHub( messageRepo: messageRepo, roomRepo: roomRepo, directChatRepo: directChatRepo, + firestoreClient: client, } } diff --git a/internal/pkg/websocket/websocket.go b/internal/pkg/websocket/websocket.go index 5524d6c..bf91d2e 100644 --- a/internal/pkg/websocket/websocket.go +++ b/internal/pkg/websocket/websocket.go @@ -1,6 +1,7 @@ package websocket import ( + "context" "encoding/json" "log" "time" @@ -143,6 +144,16 @@ func (c *Client) ReadPump() { log.Printf("Error updating last message: %v", err) } + // Obtener el displayName del usuario + ctx := context.Background() + userDoc, err := c.hub.firestoreClient.Client.Collection("users").Doc(c.userID).Get(ctx) + if err == nil { + var user models.User + if err := userDoc.DataTo(&user); err == nil { + chatMsg.DisplayName = user.DisplayName + } + } + // Convertir el mensaje de vuelta a JSON para difundir payload, _ := json.Marshal(chatMsg) wsMessage.Payload = payload @@ -200,6 +211,16 @@ func (c *Client) ReadPump() { log.Printf("Error updating last message in direct chat: %v", err) } + // Obtener el displayName del usuario + ctx := context.Background() + userDoc, err := c.hub.firestoreClient.Client.Collection("users").Doc(c.userID).Get(ctx) + if err == nil { + var user models.User + if err := userDoc.DataTo(&user); err == nil { + chatMsg.DisplayName = user.DisplayName + } + } + // Convertir el mensaje de vuelta a JSON para difundir payload, _ := json.Marshal(chatMsg) wsMessage.Payload = payload From f7bee4604e2aae18fed2c7d201cadd11ffbb353e Mon Sep 17 00:00:00 2001 From: Joan Manuel Jaramillo Avila <89425013+LifeRIP@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:04:27 -0500 Subject: [PATCH 05/12] feat: Adds user existence validation and Firebase integration - Introduces functionality to ensure a user's existence in the database, leveraging Firebase authentication data. - Updates `UserHandler` and related services to support this logic. - Adds a new route `/user/ensure` for user validation and creation. - Enhances `UserRepository` and `UserService` with methods to fetch and create users. --- docs/docs.go | 38 +++++++++++++++++++++ docs/swagger.json | 38 +++++++++++++++++++++ docs/swagger.yaml | 25 ++++++++++++++ internal/handlers/chat_handler.go | 38 ++++++++++----------- internal/handlers/user_handler.go | 43 +++++++++++++++++++++++- internal/repositories/user_repository.go | 20 +++++++++++ internal/routes/router.go | 9 +++++ internal/services/user_service.go | 28 +++++++++++++++ 8 files changed, 219 insertions(+), 20 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index ed790b8..7676e8a 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -756,6 +756,40 @@ const docTemplate = `{ } } } + }, + "/user/ensure": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Verifica si el usuario autenticado existe en la base de datos, si no, lo crea con los datos de autenticación", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "Asegura que el usuario exista en la base de datos", + "responses": { + "200": { + "description": "Datos del usuario", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "500": { + "description": "Error interno del servidor", + "schema": { + "type": "string" + } + } + } + } } }, "definitions": { @@ -829,6 +863,10 @@ const docTemplate = `{ "createdAt": { "type": "string" }, + "displayName": { + "description": "Excluido de Firestore", + "type": "string" + }, "id": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 7e57785..7fcfd37 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -749,6 +749,40 @@ } } } + }, + "/user/ensure": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Verifica si el usuario autenticado existe en la base de datos, si no, lo crea con los datos de autenticación", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "Asegura que el usuario exista en la base de datos", + "responses": { + "200": { + "description": "Datos del usuario", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "500": { + "description": "Error interno del servidor", + "schema": { + "type": "string" + } + } + } + } } }, "definitions": { @@ -822,6 +856,10 @@ "createdAt": { "type": "string" }, + "displayName": { + "description": "Excluido de Firestore", + "type": "string" + }, "id": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ecf85a5..107c9af 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -46,6 +46,9 @@ definitions: type: string createdAt: type: string + displayName: + description: Excluido de Firestore + type: string id: type: string isDeleted: @@ -626,6 +629,28 @@ paths: summary: Conexión WebSocket para chat en tiempo real tags: - Chat + /user/ensure: + post: + consumes: + - application/json + description: Verifica si el usuario autenticado existe en la base de datos, + si no, lo crea con los datos de autenticación + produces: + - application/json + responses: + "200": + description: Datos del usuario + schema: + $ref: '#/definitions/models.User' + "500": + description: Error interno del servidor + schema: + type: string + security: + - BearerAuth: [] + summary: Asegura que el usuario exista en la base de datos + tags: + - User securityDefinitions: BearerAuth: in: header diff --git a/internal/handlers/chat_handler.go b/internal/handlers/chat_handler.go index 24dd743..59b138b 100644 --- a/internal/handlers/chat_handler.go +++ b/internal/handlers/chat_handler.go @@ -127,13 +127,13 @@ func (h *ChatHandler) GetUserRooms(w http.ResponseWriter, r *http.Request) { // @Accept json // @Produce json // @Security BearerAuth -// @Param roomId path string true "ID de la sala" -// @Param limit query int false "Límite de mensajes a obtener" default(50) -// @Param cursor query string false "Cursor para paginación (timestamp)" default("1747441934") +// @Param roomId path string true "ID de la sala" +// @Param limit query int false "Límite de mensajes a obtener" default(50) +// @Param cursor query string false "Cursor para paginación (timestamp)" default("1747441934") // @Success 200 {object} models.PaginatedMessagesResponse "Mensajes paginados de la sala" -// @Failure 401 {string} string "No autorizado" -// @Failure 404 {string} string "Sala no encontrada" -// @Failure 500 {string} string "Error interno del servidor" +// @Failure 401 {string} string "No autorizado" +// @Failure 404 {string} string "Sala no encontrada" +// @Failure 500 {string} string "Error interno del servidor" // @Router /chat/rooms/{roomId}/messages/paginated [get] func (h *ChatHandler) GetRoomMessages(w http.ResponseWriter, r *http.Request) { roomID := chi.URLParam(r, "roomId") @@ -342,19 +342,19 @@ func (h *ChatHandler) JoinRoom(w http.ResponseWriter, r *http.Request) { // GetRoomMessagesSimple obtiene los mensajes de una sala sin paginación // -// @Summary Obtiene mensajes de una sala (versión simple) -// @Description Devuelve los mensajes de una sala específica sin paginación -// @Tags Chat -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param roomId path string true "ID de la sala" -// @Param limit query int false "Límite de mensajes a obtener" default(50) -// @Success 200 {array} models.MessageResponse "Lista de mensajes de la sala" -// @Failure 401 {string} string "No autorizado" -// @Failure 404 {string} string "Sala no encontrada" -// @Failure 500 {string} string "Error interno del servidor" -// @Router /chat/rooms/{roomId}/messages [get] +// @Summary Obtiene mensajes de una sala (versión simple) +// @Description Devuelve los mensajes de una sala específica sin paginación +// @Tags Chat +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param roomId path string true "ID de la sala" +// @Param limit query int false "Límite de mensajes a obtener" default(50) +// @Success 200 {array} models.MessageResponse "Lista de mensajes de la sala" +// @Failure 401 {string} string "No autorizado" +// @Failure 404 {string} string "Sala no encontrada" +// @Failure 500 {string} string "Error interno del servidor" +// @Router /chat/rooms/{roomId}/messages [get] func (h *ChatHandler) GetRoomMessagesSimple(w http.ResponseWriter, r *http.Request) { roomID := chi.URLParam(r, "roomId") diff --git a/internal/handlers/user_handler.go b/internal/handlers/user_handler.go index a2a1cd4..70a8454 100644 --- a/internal/handlers/user_handler.go +++ b/internal/handlers/user_handler.go @@ -10,12 +10,14 @@ import ( type UserHandler struct { UserService *services.UserService + AuthService *services.AuthService } // NewUserHandler crea una nueva instancia de UserHandler -func NewUserHandler(userService *services.UserService) *UserHandler { +func NewUserHandler(userService *services.UserService, authService *services.AuthService) *UserHandler { return &UserHandler{ UserService: userService, + AuthService: authService, } } @@ -35,3 +37,42 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(user) } + +// EnsureUserExists verifica si el usuario existe en la base de datos, si no, lo crea con los datos de autenticación +// +// @Summary Asegura que el usuario exista en la base de datos +// @Description Verifica si el usuario autenticado existe en la base de datos, si no, lo crea con los datos de autenticación +// @Tags User +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} models.User "Datos del usuario" +// @Failure 500 {string} string "Error interno del servidor" +// @Router /user/ensure [post] +func (h *UserHandler) EnsureUserExists(w http.ResponseWriter, r *http.Request) { + // Obtener el ID del usuario del contexto + userID, ok := r.Context().Value("userID").(string) + if !ok { + http.Error(w, "User ID not found in context", http.StatusInternalServerError) + return + } + + // Obtener los datos del usuario desde Firebase Auth + authUser, err := h.AuthService.GetUserByID(r.Context(), userID) + if err != nil { + http.Error(w, "Error getting user from auth: "+err.Error(), http.StatusInternalServerError) + return + } + println("Auth User:", authUser.UID, authUser.Email, authUser.DisplayName) + + // Asegurar que el usuario exista en la base de datos + user, err := h.UserService.EnsureUserExists(r.Context(), authUser) + if err != nil { + http.Error(w, "Error ensuring user exists: "+err.Error(), http.StatusInternalServerError) + return + } + + // Responder con los datos del usuario + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(user) +} diff --git a/internal/repositories/user_repository.go b/internal/repositories/user_repository.go index 1de3a3f..2e238f6 100644 --- a/internal/repositories/user_repository.go +++ b/internal/repositories/user_repository.go @@ -35,3 +35,23 @@ func (r *UserRepository) CreateUser(user *models.User) error { return nil } + +// GetUserByID obtiene un usuario de la base de datos por su ID +func (r *UserRepository) GetUserByID(ctx context.Context, userID string) (*models.User, error) { + docRef := r.FirestoreClient.Client.Collection("users").Doc(userID) + docSnap, err := docRef.Get(ctx) + if err != nil { + return nil, err + } + + if !docSnap.Exists() { + return nil, nil // El usuario no existe + } + + var user models.User + if err := docSnap.DataTo(&user); err != nil { + return nil, err + } + + return &user, nil +} diff --git a/internal/routes/router.go b/internal/routes/router.go index 90021b0..178e23d 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -14,6 +14,7 @@ import ( // NewRouter crea un nuevo router HTTP func NewRouter( authHandler *handlers.AuthHandler, + userHandler *handlers.UserHandler, // Add userHandler parameter chatHandler *handlers.ChatHandler, webSocketHandler *handlers.WebSocketHandler, authMw *authMiddleware.AuthMiddleware, @@ -45,6 +46,14 @@ func NewRouter( // Rutas protegidas (requieren token) r.Route("/api/v1", func(r chi.Router) { // Rutas de usuario + r.Route("/user", func(r chi.Router) { + r.Group(func(r chi.Router) { + r.Use(authMw.VerifyToken) // Aplicar middleware de autenticación + r.Post("/create", userHandler.EnsureUserExists) // Nueva ruta para asegurar que el usuario exista + }) + }) + + // Rutas de autenticación r.Route("/auth", func(r chi.Router) { r.Post("/signup", authHandler.SignUpAndCreateUser) // Ruta para registrar y crear un nuevo usuario diff --git a/internal/services/user_service.go b/internal/services/user_service.go index 631bbc7..e3c56a7 100644 --- a/internal/services/user_service.go +++ b/internal/services/user_service.go @@ -1,6 +1,8 @@ package services import ( + "context" + "github.com/Parchat/backend/internal/config" "github.com/Parchat/backend/internal/models" "github.com/Parchat/backend/internal/repositories" @@ -28,3 +30,29 @@ func (s *UserService) CreateUser(user *models.User) error { return nil } + +// GetUserByID obtiene un usuario de la base de datos por su ID +func (s *UserService) GetUserByID(ctx context.Context, userID string) (*models.User, error) { + return s.UserRepo.GetUserByID(ctx, userID) +} + +// EnsureUserExists verifica si el usuario existe en la base de datos, si no, lo crea +func (s *UserService) EnsureUserExists(ctx context.Context, authUser *models.User) (*models.User, error) { + // Verificar si el usuario ya existe en la base de datos + user, err := s.GetUserByID(ctx, authUser.UID) + + // Si hay un error pero NO es del tipo "no encontrado", retornamos el error + // Si es un error de "no encontrado" o si no hay error pero user es nil, creamos el usuario + if err == nil && user != nil { + // Usuario encontrado, lo retornamos + return user, nil + } + + // Si llegamos aquí, o hubo un error de "usuario no encontrado" o user es nil, + // en ambos casos queremos crear un nuevo usuario + err = s.CreateUser(authUser) + if err != nil { + return nil, err + } + return authUser, nil +} From 51a2425d2c2c072e6167a02e03301deaca358da8 Mon Sep 17 00:00:00 2001 From: Joan Manuel Jaramillo Avila <89425013+LifeRIP@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:08:54 -0500 Subject: [PATCH 06/12] feat: Rename user endpoint from /user/ensure to /user/create in docs --- docs/docs.go | 2 +- docs/swagger.json | 2 +- docs/swagger.yaml | 2 +- internal/handlers/user_handler.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 7676e8a..06bcd0c 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -757,7 +757,7 @@ const docTemplate = `{ } } }, - "/user/ensure": { + "/user/create": { "post": { "security": [ { diff --git a/docs/swagger.json b/docs/swagger.json index 7fcfd37..f1a79d3 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -750,7 +750,7 @@ } } }, - "/user/ensure": { + "/user/create": { "post": { "security": [ { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 107c9af..24948a1 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -629,7 +629,7 @@ paths: summary: Conexión WebSocket para chat en tiempo real tags: - Chat - /user/ensure: + /user/create: post: consumes: - application/json diff --git a/internal/handlers/user_handler.go b/internal/handlers/user_handler.go index 70a8454..10151e2 100644 --- a/internal/handlers/user_handler.go +++ b/internal/handlers/user_handler.go @@ -48,7 +48,7 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { // @Security BearerAuth // @Success 200 {object} models.User "Datos del usuario" // @Failure 500 {string} string "Error interno del servidor" -// @Router /user/ensure [post] +// @Router /user/create [post] func (h *UserHandler) EnsureUserExists(w http.ResponseWriter, r *http.Request) { // Obtener el ID del usuario del contexto userID, ok := r.Context().Value("userID").(string) From 91ef0f74b14dfeaf4532245fc115cf497d7de871 Mon Sep 17 00:00:00 2001 From: Joan Manuel Jaramillo Avila <89425013+LifeRIP@users.noreply.github.com> Date: Tue, 17 Jun 2025 21:24:55 -0500 Subject: [PATCH 07/12] feat: Adds user display names to DirectChat model - Enhances the DirectChat model by introducing `DisplayNames`, enabling retrieval of user names alongside IDs. - Updates repository logic to populate `DisplayNames` by fetching user details from the UserRepository, improving readability and usability of direct chat data. --- internal/models/directchat.go | 13 +++++++------ internal/repositories/directchat_repository.go | 14 +++++++++++++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/internal/models/directchat.go b/internal/models/directchat.go index 210ace5..583dfb4 100644 --- a/internal/models/directchat.go +++ b/internal/models/directchat.go @@ -4,10 +4,11 @@ import "time" // DirectChat representa un chat directo entre dos usuarios type DirectChat struct { - ID string `json:"id" firestore:"id"` - UserIDs []string `json:"userIds" firestore:"userIds"` - LastMessage *Message `json:"lastMessage,omitempty" firestore:"lastMessage,omitempty"` - CreatedAt time.Time `json:"createdAt" firestore:"createdAt"` - UpdatedAt time.Time `json:"updatedAt" firestore:"updatedAt"` - IsDeleted bool `json:"isDeleted" firestore:"isDeleted"` + ID string `json:"id" firestore:"id"` + UserIDs []string `json:"userIds" firestore:"userIds"` + DisplayNames []string `json:"displayNames" firestore:"displayNames,omitempty"` + LastMessage *Message `json:"lastMessage,omitempty" firestore:"lastMessage,omitempty"` + CreatedAt time.Time `json:"createdAt" firestore:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" firestore:"updatedAt"` + IsDeleted bool `json:"isDeleted" firestore:"isDeleted"` } diff --git a/internal/repositories/directchat_repository.go b/internal/repositories/directchat_repository.go index c786e8e..db17225 100644 --- a/internal/repositories/directchat_repository.go +++ b/internal/repositories/directchat_repository.go @@ -13,12 +13,14 @@ import ( // DirectChatRepository maneja las operaciones de base de datos para los chats directos type DirectChatRepository struct { FirestoreClient *config.FirestoreClient + UserRepo *UserRepository } // NewDirectChatRepository crea una nueva instancia de DirectChatRepository -func NewDirectChatRepository(client *config.FirestoreClient) *DirectChatRepository { +func NewDirectChatRepository(client *config.FirestoreClient, userRepo *UserRepository) *DirectChatRepository { return &DirectChatRepository{ FirestoreClient: client, + UserRepo: userRepo, } } @@ -111,6 +113,16 @@ func (r *DirectChatRepository) GetUserDirectChats(userID string) ([]models.Direc if err := doc.DataTo(&chat); err != nil { return nil, err } + + // Obtener los nombres actualizados de los usuarios + chat.DisplayNames = make([]string, len(chat.UserIDs)) + for i, id := range chat.UserIDs { + user, err := r.UserRepo.GetUserByID(ctx, id) + if err == nil && user != nil { + chat.DisplayNames[i] = user.DisplayName + } + } + chats = append(chats, chat) } From b722e913f5a189d384bbd8d70426b6e705c25aee Mon Sep 17 00:00:00 2001 From: Joan Manuel Jaramillo Avila <89425013+LifeRIP@users.noreply.github.com> Date: Tue, 17 Jun 2025 21:54:47 -0500 Subject: [PATCH 08/12] feat: Adds user access validation and improves message ordering - Enforces user access validation for direct chats, ensuring unauthorized users cannot access messages. - Revises message retrieval logic for direct and room chats to return messages in descending order and subsequently reverse them for ascending order in the response. --- internal/handlers/chat_handler.go | 14 ++++++++++ internal/repositories/message_repository.go | 29 ++++++++++++++++----- internal/services/directchat_service.go | 2 +- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/internal/handlers/chat_handler.go b/internal/handlers/chat_handler.go index 59b138b..468ceb5 100644 --- a/internal/handlers/chat_handler.go +++ b/internal/handlers/chat_handler.go @@ -245,12 +245,26 @@ func (h *ChatHandler) GetUserDirectChats(w http.ResponseWriter, r *http.Request) // @Param limit query int false "Límite de mensajes a obtener" default(50) // @Success 200 {array} models.Message "Lista de mensajes del chat directo" // @Failure 401 {string} string "No autorizado" +// @Failure 403 {string} string "Acceso prohibido" // @Failure 404 {string} string "Chat no encontrado" // @Failure 500 {string} string "Error interno del servidor" // @Router /chat/direct/{chatId}/messages [get] func (h *ChatHandler) GetDirectChatMessages(w http.ResponseWriter, r *http.Request) { chatID := chi.URLParam(r, "chatId") + // Obtener el ID del usuario del contexto + userID, ok := r.Context().Value("userID").(string) + if !ok { + http.Error(w, "User ID not found in context", http.StatusInternalServerError) + return + } + + // Verificar si el usuario pertenece al chat directo + if !h.DirectChatService.DirectChatRepo.IsUserInDirectChat(chatID, userID) { + http.Error(w, "Unauthorized access to this chat", http.StatusForbidden) + return + } + limitStr := r.URL.Query().Get("limit") limit := 50 // valor por defecto diff --git a/internal/repositories/message_repository.go b/internal/repositories/message_repository.go index cc6d5b9..c50c6f4 100644 --- a/internal/repositories/message_repository.go +++ b/internal/repositories/message_repository.go @@ -139,17 +139,18 @@ func (r *MessageRepository) GetRoomMessages(roomID string, limit int, cursor str return response, nextCursor, nil } -// GetDirectChatMessages obtiene los mensajes de un chat directo -func (r *MessageRepository) GetDirectChatMessages(directChatID string, limit int) ([]models.MessageResponse, error) { +// GetDirectChatMessagesSimple obtiene los mensajes de un chat directo sin paginación +func (r *MessageRepository) GetDirectChatMessagesSimple(directChatID string, limit int) ([]models.MessageResponse, error) { ctx := context.Background() var messages []models.Message var response []models.MessageResponse + // Obtener mensajes en orden descendente (más recientes primero) messagesRef := r.FirestoreClient.Client. Collection("directChats").Doc(directChatID). Collection("messages"). - OrderBy("createdAt", firestore.Asc). + OrderBy("createdAt", firestore.Desc). Limit(limit) docs, err := messagesRef.Documents(ctx).GetAll() @@ -182,6 +183,8 @@ func (r *MessageRepository) GetDirectChatMessages(directChatID string, limit int } } + // Crear respuestas con DisplayName + var responseTemp []models.MessageResponse for _, message := range messages { msgMap := models.MessageResponse{ Message: message, @@ -192,7 +195,12 @@ func (r *MessageRepository) GetDirectChatMessages(directChatID string, limit int msgMap.DisplayName = displayName } - response = append(response, msgMap) + responseTemp = append(responseTemp, msgMap) + } + + // Invertir el orden para que queden en orden ascendente (más antiguos primero) + for i := len(responseTemp) - 1; i >= 0; i-- { + response = append(response, responseTemp[i]) } return response, nil @@ -205,10 +213,11 @@ func (r *MessageRepository) GetRoomMessagesSimple(roomID string, limit int) ([]m var messages []models.Message var response []models.MessageResponse + // Obtener mensajes en orden descendente (más recientes primero) messagesRef := r.FirestoreClient.Client. Collection("rooms").Doc(roomID). Collection("messages"). - OrderBy("createdAt", firestore.Asc). + OrderBy("createdAt", firestore.Desc). Limit(limit) docs, err := messagesRef.Documents(ctx).GetAll() @@ -243,13 +252,19 @@ func (r *MessageRepository) GetRoomMessagesSimple(roomID string, limit int) ([]m } } - // Construir la respuesta con los displayNames + // Crear respuestas con DisplayName + var responseTemp []models.MessageResponse for _, message := range messages { msgResponse := models.MessageResponse{ Message: message, DisplayName: userDataCache[message.UserID], // Puede estar vacío si no se encontró } - response = append(response, msgResponse) + responseTemp = append(responseTemp, msgResponse) + } + + // Invertir el orden para que queden en orden ascendente (más antiguos primero) + for i := len(responseTemp) - 1; i >= 0; i-- { + response = append(response, responseTemp[i]) } return response, nil diff --git a/internal/services/directchat_service.go b/internal/services/directchat_service.go index c6006eb..03fb897 100644 --- a/internal/services/directchat_service.go +++ b/internal/services/directchat_service.go @@ -69,7 +69,7 @@ func (s *DirectChatService) GetUserDirectChatsWithSenderNames(userID string) ([] // GetDirectChatMessages obtiene los mensajes de un chat directo func (s *DirectChatService) GetDirectChatMessages(directChatID string, limit int) ([]models.MessageResponse, error) { - return s.MessageRepo.GetDirectChatMessages(directChatID, limit) + return s.MessageRepo.GetDirectChatMessagesSimple(directChatID, limit) } // FindOrCreateDirectChat encuentra un chat directo entre dos usuarios o lo crea si no existe From c60b71b15703326705361129ba1fe7bc004b2e3f Mon Sep 17 00:00:00 2001 From: Joan Manuel Jaramillo Avila <89425013+LifeRIP@users.noreply.github.com> Date: Tue, 17 Jun 2025 21:55:47 -0500 Subject: [PATCH 09/12] feat: Add 403 Forbidden response and displayNames to Swagger documentation --- docs/docs.go | 12 ++++++++++++ docs/swagger.json | 12 ++++++++++++ docs/swagger.yaml | 8 ++++++++ 3 files changed, 32 insertions(+) diff --git a/docs/docs.go b/docs/docs.go index 06bcd0c..7f53303 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -252,6 +252,12 @@ const docTemplate = `{ "type": "string" } }, + "403": { + "description": "Acceso prohibido", + "schema": { + "type": "string" + } + }, "404": { "description": "Chat no encontrado", "schema": { @@ -834,6 +840,12 @@ const docTemplate = `{ "createdAt": { "type": "string" }, + "displayNames": { + "type": "array", + "items": { + "type": "string" + } + }, "id": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index f1a79d3..9c54e57 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -245,6 +245,12 @@ "type": "string" } }, + "403": { + "description": "Acceso prohibido", + "schema": { + "type": "string" + } + }, "404": { "description": "Chat no encontrado", "schema": { @@ -827,6 +833,12 @@ "createdAt": { "type": "string" }, + "displayNames": { + "type": "array", + "items": { + "type": "string" + } + }, "id": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 24948a1..c6e2846 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -27,6 +27,10 @@ definitions: properties: createdAt: type: string + displayNames: + items: + type: string + type: array id: type: string isDeleted: @@ -273,6 +277,10 @@ paths: description: No autorizado schema: type: string + "403": + description: Acceso prohibido + schema: + type: string "404": description: Chat no encontrado schema: From 545fe0bd4c61db73861eaba5434e9f8ed8f2f9e2 Mon Sep 17 00:00:00 2001 From: Joan Manuel Jaramillo Avila <89425013+LifeRIP@users.noreply.github.com> Date: Sat, 21 Jun 2025 22:31:22 -0500 Subject: [PATCH 10/12] feat: Add moderation features for reporting and banning users - Introduces content moderation capabilities, including reporting inappropriate messages, banning users based on report thresholds, and clearing user reports in chat rooms. - Adds new handlers, services, and repositories to manage these features. - Updates WebSocket logic to prevent banned users from sending messages and modifies room models to track reported users. - Enhances API with moderation-specific endpoints and integrates moderation checks into existing workflows. --- cmd/api/main.go | 3 + docs/docs.go | 264 ++++++++++++++++++++ docs/swagger.json | 264 ++++++++++++++++++++ docs/swagger.yaml | 170 +++++++++++++ internal/handlers/moderation_handler.go | 207 +++++++++++++++ internal/models/report.go | 37 +++ internal/models/room.go | 25 +- internal/pkg/websocket/hub.go | 3 + internal/pkg/websocket/websocket.go | 20 +- internal/repositories/message_repository.go | 22 ++ internal/repositories/report_repository.go | 145 +++++++++++ internal/routes/router.go | 6 +- internal/services/moderation_service.go | 179 +++++++++++++ internal/services/room_service.go | 22 ++ 14 files changed, 1351 insertions(+), 16 deletions(-) create mode 100644 internal/handlers/moderation_handler.go create mode 100644 internal/models/report.go create mode 100644 internal/repositories/report_repository.go create mode 100644 internal/services/moderation_service.go diff --git a/cmd/api/main.go b/cmd/api/main.go index 2cdebad..bb29240 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -49,13 +49,16 @@ func main() { repositories.NewRoomRepository, repositories.NewDirectChatRepository, repositories.NewMessageRepository, + repositories.NewReportRepository, services.NewAuthService, services.NewUserService, services.NewRoomService, services.NewDirectChatService, + services.NewModerationService, handlers.NewAuthHandler, handlers.NewUserHandler, handlers.NewChatHandler, + handlers.NewModerationHandler, middleware.NewAuthMiddleware, // Proveedores de WebSocket diff --git a/docs/docs.go b/docs/docs.go index 7f53303..6ef3c57 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -524,6 +524,143 @@ const docTemplate = `{ } } }, + "/chat/rooms/{roomId}/banned-users": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieves a list of users who have been banned in a chat room", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Moderation" + ], + "summary": "Get banned users in a room", + "parameters": [ + { + "type": "string", + "description": "Room ID", + "name": "roomId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Banned users", + "schema": { + "$ref": "#/definitions/models.BannedUsersResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/chat/rooms/{roomId}/clear-reports": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Clears all reports for a specific user in a chat room", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Moderation" + ], + "summary": "Clear reports for a user", + "parameters": [ + { + "type": "string", + "description": "Room ID", + "name": "roomId", + "in": "path", + "required": true + }, + { + "description": "Clear Report Request", + "name": "clearRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ClearReportRequest" + } + } + ], + "responses": { + "200": { + "description": "Reports cleared successfully", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, "/chat/rooms/{roomId}/join": { "post": { "security": [ @@ -725,6 +862,82 @@ const docTemplate = `{ } } }, + "/chat/rooms/{roomId}/report": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Reports a message as inappropriate in a chat room", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Moderation" + ], + "summary": "Report an inappropriate message", + "parameters": [ + { + "type": "string", + "description": "Room ID", + "name": "roomId", + "in": "path", + "required": true + }, + { + "description": "Report Request", + "name": "report", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ReportRequest" + } + } + ], + "responses": { + "200": { + "description": "Message reported successfully", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, "/chat/ws": { "get": { "description": "Establece una conexión WebSocket para mensajería en tiempo real", @@ -813,6 +1026,39 @@ const docTemplate = `{ } } }, + "models.BannedUserResponse": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "reportCount": { + "type": "integer" + }, + "userId": { + "type": "string" + } + } + }, + "models.BannedUsersResponse": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/models.BannedUserResponse" + } + } + } + }, + "models.ClearReportRequest": { + "type": "object", + "properties": { + "userId": { + "type": "string" + } + } + }, "models.CreateRoomRequest": { "type": "object", "properties": { @@ -942,6 +1188,17 @@ const docTemplate = `{ } } }, + "models.ReportRequest": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, "models.Room": { "type": "object", "properties": { @@ -984,6 +1241,13 @@ const docTemplate = `{ "ownerId": { "type": "string" }, + "reportedUsers": { + "description": "Map of userID to report count", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, "updatedAt": { "type": "string" } diff --git a/docs/swagger.json b/docs/swagger.json index 9c54e57..a5520d9 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -517,6 +517,143 @@ } } }, + "/chat/rooms/{roomId}/banned-users": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieves a list of users who have been banned in a chat room", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Moderation" + ], + "summary": "Get banned users in a room", + "parameters": [ + { + "type": "string", + "description": "Room ID", + "name": "roomId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Banned users", + "schema": { + "$ref": "#/definitions/models.BannedUsersResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/chat/rooms/{roomId}/clear-reports": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Clears all reports for a specific user in a chat room", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Moderation" + ], + "summary": "Clear reports for a user", + "parameters": [ + { + "type": "string", + "description": "Room ID", + "name": "roomId", + "in": "path", + "required": true + }, + { + "description": "Clear Report Request", + "name": "clearRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ClearReportRequest" + } + } + ], + "responses": { + "200": { + "description": "Reports cleared successfully", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, "/chat/rooms/{roomId}/join": { "post": { "security": [ @@ -718,6 +855,82 @@ } } }, + "/chat/rooms/{roomId}/report": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Reports a message as inappropriate in a chat room", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Moderation" + ], + "summary": "Report an inappropriate message", + "parameters": [ + { + "type": "string", + "description": "Room ID", + "name": "roomId", + "in": "path", + "required": true + }, + { + "description": "Report Request", + "name": "report", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ReportRequest" + } + } + ], + "responses": { + "200": { + "description": "Message reported successfully", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, "/chat/ws": { "get": { "description": "Establece una conexión WebSocket para mensajería en tiempo real", @@ -806,6 +1019,39 @@ } } }, + "models.BannedUserResponse": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "reportCount": { + "type": "integer" + }, + "userId": { + "type": "string" + } + } + }, + "models.BannedUsersResponse": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/models.BannedUserResponse" + } + } + } + }, + "models.ClearReportRequest": { + "type": "object", + "properties": { + "userId": { + "type": "string" + } + } + }, "models.CreateRoomRequest": { "type": "object", "properties": { @@ -935,6 +1181,17 @@ } } }, + "models.ReportRequest": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, "models.Room": { "type": "object", "properties": { @@ -977,6 +1234,13 @@ "ownerId": { "type": "string" }, + "reportedUsers": { + "description": "Map of userID to report count", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, "updatedAt": { "type": "string" } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c6e2846..2d8d705 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -9,6 +9,27 @@ definitions: password: type: string type: object + models.BannedUserResponse: + properties: + displayName: + type: string + reportCount: + type: integer + userId: + type: string + type: object + models.BannedUsersResponse: + properties: + users: + items: + $ref: '#/definitions/models.BannedUserResponse' + type: array + type: object + models.ClearReportRequest: + properties: + userId: + type: string + type: object models.CreateRoomRequest: properties: description: @@ -94,6 +115,13 @@ definitions: nextCursor: type: string type: object + models.ReportRequest: + properties: + messageId: + type: string + reason: + type: string + type: object models.Room: properties: admins: @@ -122,6 +150,11 @@ definitions: type: string ownerId: type: string + reportedUsers: + additionalProperties: + type: integer + description: Map of userID to report count + type: object updatedAt: type: string type: object @@ -454,6 +487,94 @@ paths: summary: Obtiene una sala por ID tags: - Chat + /chat/rooms/{roomId}/banned-users: + get: + consumes: + - application/json + description: Retrieves a list of users who have been banned in a chat room + parameters: + - description: Room ID + in: path + name: roomId + required: true + type: string + produces: + - application/json + responses: + "200": + description: Banned users + schema: + $ref: '#/definitions/models.BannedUsersResponse' + "401": + description: Unauthorized + schema: + type: string + "403": + description: Forbidden + schema: + type: string + "404": + description: Not found + schema: + type: string + "500": + description: Internal server error + schema: + type: string + security: + - BearerAuth: [] + summary: Get banned users in a room + tags: + - Moderation + /chat/rooms/{roomId}/clear-reports: + post: + consumes: + - application/json + description: Clears all reports for a specific user in a chat room + parameters: + - description: Room ID + in: path + name: roomId + required: true + type: string + - description: Clear Report Request + in: body + name: clearRequest + required: true + schema: + $ref: '#/definitions/models.ClearReportRequest' + produces: + - application/json + responses: + "200": + description: Reports cleared successfully + schema: + type: string + "400": + description: Invalid request + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: Forbidden + schema: + type: string + "404": + description: Not found + schema: + type: string + "500": + description: Internal server error + schema: + type: string + security: + - BearerAuth: [] + summary: Clear reports for a user + tags: + - Moderation /chat/rooms/{roomId}/join: post: consumes: @@ -585,6 +706,55 @@ paths: summary: Obtiene mensajes de una sala tags: - Chat + /chat/rooms/{roomId}/report: + post: + consumes: + - application/json + description: Reports a message as inappropriate in a chat room + parameters: + - description: Room ID + in: path + name: roomId + required: true + type: string + - description: Report Request + in: body + name: report + required: true + schema: + $ref: '#/definitions/models.ReportRequest' + produces: + - application/json + responses: + "200": + description: Message reported successfully + schema: + type: string + "400": + description: Invalid request + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: Forbidden + schema: + type: string + "404": + description: Not found + schema: + type: string + "500": + description: Internal server error + schema: + type: string + security: + - BearerAuth: [] + summary: Report an inappropriate message + tags: + - Moderation /chat/rooms/me: get: consumes: diff --git a/internal/handlers/moderation_handler.go b/internal/handlers/moderation_handler.go new file mode 100644 index 0000000..2ab117c --- /dev/null +++ b/internal/handlers/moderation_handler.go @@ -0,0 +1,207 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/Parchat/backend/internal/models" + "github.com/Parchat/backend/internal/services" + "github.com/go-chi/chi/v5" +) + +// ModerationHandler handles the HTTP requests related to content moderation +type ModerationHandler struct { + moderationService *services.ModerationService + roomService *services.RoomService +} + +// NewModerationHandler creates a new instance of ModerationHandler +func NewModerationHandler( + moderationService *services.ModerationService, + roomService *services.RoomService, +) *ModerationHandler { + return &ModerationHandler{ + moderationService: moderationService, + roomService: roomService, + } +} + +// ReportMessage handles the request to report an inappropriate message +// +// @Summary Report an inappropriate message +// @Description Reports a message as inappropriate in a chat room +// @Tags Moderation +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param roomId path string true "Room ID" +// @Param report body models.ReportRequest true "Report Request" +// @Success 200 {string} string "Message reported successfully" +// @Failure 400 {string} string "Invalid request" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "Forbidden" +// @Failure 404 {string} string "Not found" +// @Failure 500 {string} string "Internal server error" +// @Router /chat/rooms/{roomId}/report [post] +func (h *ModerationHandler) ReportMessage(w http.ResponseWriter, r *http.Request) { + // Get the room ID from the URL + roomID := chi.URLParam(r, "roomId") + if roomID == "" { + http.Error(w, "Room ID is required", http.StatusBadRequest) + return + } + + // Obtener el ID del usuario del contexto + userID, ok := r.Context().Value("userID").(string) + if !ok { + http.Error(w, "User ID not found in context", http.StatusInternalServerError) + return + } + + // Parse the request body + var reportReq models.ReportRequest + if err := json.NewDecoder(r.Body).Decode(&reportReq); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate the request + if reportReq.MessageID == "" { + http.Error(w, "Message ID is required", http.StatusBadRequest) + return + } + + // Call the service to report the message + err := h.moderationService.ReportMessage(userID, roomID, reportReq.MessageID, reportReq.Reason) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Return success response + w.WriteHeader(http.StatusOK) + w.Write([]byte("Message reported successfully")) +} + +// GetBannedUsers handles the request to get all banned users in a room +// +// @Summary Get banned users in a room +// @Description Retrieves a list of users who have been banned in a chat room +// @Tags Moderation +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param roomId path string true "Room ID" +// @Success 200 {object} models.BannedUsersResponse "Banned users" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "Forbidden" +// @Failure 404 {string} string "Not found" +// @Failure 500 {string} string "Internal server error" +// @Router /chat/rooms/{roomId}/banned-users [get] +func (h *ModerationHandler) GetBannedUsers(w http.ResponseWriter, r *http.Request) { + // Get the room ID from the URL + roomID := chi.URLParam(r, "roomId") + if roomID == "" { + http.Error(w, "Room ID is required", http.StatusBadRequest) + return + } + + // Obtener el ID del usuario del contexto + userID, ok := r.Context().Value("userID").(string) + if !ok { + http.Error(w, "User ID not found in context", http.StatusInternalServerError) + return + } + + // Check if the user is an admin or owner of the room + isAuthorized, err := h.roomService.IsUserAdminOrOwner(roomID, userID) + if err != nil { + http.Error(w, "Error checking user authorization", http.StatusInternalServerError) + return + } + + if !isAuthorized { + http.Error(w, "Unauthorized: Only room admins or owner can view reported users", http.StatusForbidden) + return + } + // Get banned users in the room + bannedUsers, err := h.moderationService.GetBannedUsersInRoom(roomID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Return the response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(bannedUsers) +} + +// ClearUserReports handles the request to clear all reports for a user in a room +// +// @Summary Clear reports for a user +// @Description Clears all reports for a specific user in a chat room +// @Tags Moderation +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param roomId path string true "Room ID" +// @Param clearRequest body models.ClearReportRequest true "Clear Report Request" +// @Success 200 {string} string "Reports cleared successfully" +// @Failure 400 {string} string "Invalid request" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "Forbidden" +// @Failure 404 {string} string "Not found" +// @Failure 500 {string} string "Internal server error" +// @Router /chat/rooms/{roomId}/clear-reports [post] +func (h *ModerationHandler) ClearUserReports(w http.ResponseWriter, r *http.Request) { + // Get the room ID from the URL + roomID := chi.URLParam(r, "roomId") + if roomID == "" { + http.Error(w, "Room ID is required", http.StatusBadRequest) + return + } + + // Obtener el ID del usuario del contexto + userID, ok := r.Context().Value("userID").(string) + if !ok { + http.Error(w, "User ID not found in context", http.StatusInternalServerError) + return + } + + // Check if the user is an admin or owner of the room + isAuthorized, err := h.roomService.IsUserAdminOrOwner(roomID, userID) + if err != nil { + http.Error(w, "Error checking user authorization", http.StatusInternalServerError) + return + } + + if !isAuthorized { + http.Error(w, "Unauthorized: Only room admins or owner can clear reports", http.StatusForbidden) + return + } + + // Parse the request body + var clearReq models.ClearReportRequest + if err := json.NewDecoder(r.Body).Decode(&clearReq); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate the request + if clearReq.UserID == "" { + http.Error(w, "User ID is required", http.StatusBadRequest) + return + } + + // Call the service to clear the reports + err = h.moderationService.ClearReportsForUser(roomID, clearReq.UserID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Return success response + w.WriteHeader(http.StatusOK) + w.Write([]byte("Reports cleared successfully")) +} diff --git a/internal/models/report.go b/internal/models/report.go new file mode 100644 index 0000000..c08dc0d --- /dev/null +++ b/internal/models/report.go @@ -0,0 +1,37 @@ +package models + +import "time" + +// Report represents a user report for an inappropriate message +type Report struct { + ID string `json:"id" firestore:"id"` + MessageID string `json:"messageId" firestore:"messageId"` + RoomID string `json:"roomId" firestore:"roomId"` + ReportedID string `json:"reportedId" firestore:"reportedId"` // ID of the user being reported + ReporterID string `json:"reporterId" firestore:"reporterId"` // ID of the user making the report + Reason string `json:"reason" firestore:"reason"` + CreatedAt time.Time `json:"createdAt" firestore:"createdAt"` +} + +// ReportRequest represents the request to report a message +type ReportRequest struct { + MessageID string `json:"messageId"` + Reason string `json:"reason,omitempty"` +} + +// BannedUserResponse represents a user who has been banned due to reports +type BannedUserResponse struct { + UserID string `json:"userId"` + DisplayName string `json:"displayName"` + ReportCount int `json:"reportCount"` +} + +// BannedUsersResponse represents a list of banned users in a room +type BannedUsersResponse struct { + Users []BannedUserResponse `json:"users"` +} + +// ClearReportRequest represents the request to clear reports for a user in a room +type ClearReportRequest struct { + UserID string `json:"userId"` +} diff --git a/internal/models/room.go b/internal/models/room.go index 93cc425..ec739ca 100644 --- a/internal/models/room.go +++ b/internal/models/room.go @@ -4,18 +4,19 @@ import "time" // Room representa una sala de chat type Room struct { - ID string `json:"id" firestore:"id"` - Name string `json:"name" firestore:"name"` - Description string `json:"description" firestore:"description"` - OwnerID string `json:"ownerId" firestore:"ownerId"` - IsPrivate bool `json:"isPrivate" firestore:"isPrivate"` - Members []string `json:"members" firestore:"members"` - Admins []string `json:"admins" firestore:"admins"` - LastMessage *Message `json:"lastMessage,omitempty" firestore:"lastMessage,omitempty"` - ImageURL string `json:"imageUrl" firestore:"imageUrl"` - CreatedAt time.Time `json:"createdAt" firestore:"createdAt"` - UpdatedAt time.Time `json:"updatedAt" firestore:"updatedAt"` - IsDeleted bool `json:"isDeleted" firestore:"isDeleted"` + ID string `json:"id" firestore:"id"` + Name string `json:"name" firestore:"name"` + Description string `json:"description" firestore:"description"` + OwnerID string `json:"ownerId" firestore:"ownerId"` + IsPrivate bool `json:"isPrivate" firestore:"isPrivate"` + Members []string `json:"members" firestore:"members"` + Admins []string `json:"admins" firestore:"admins"` + LastMessage *Message `json:"lastMessage,omitempty" firestore:"lastMessage,omitempty"` + ImageURL string `json:"imageUrl" firestore:"imageUrl"` + CreatedAt time.Time `json:"createdAt" firestore:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" firestore:"updatedAt"` + IsDeleted bool `json:"isDeleted" firestore:"isDeleted"` + ReportedUsers map[string]int `json:"reportedUsers" firestore:"reportedUsers"` // Map of userID to report count } // CreateRoomRequest represents the request body for creating a new chat room diff --git a/internal/pkg/websocket/hub.go b/internal/pkg/websocket/hub.go index deee21e..413eba0 100644 --- a/internal/pkg/websocket/hub.go +++ b/internal/pkg/websocket/hub.go @@ -33,6 +33,7 @@ type Hub struct { messageRepo *repositories.MessageRepository roomRepo *repositories.RoomRepository directChatRepo *repositories.DirectChatRepository + reportRepo *repositories.ReportRepository // Firestore client firestoreClient *config.FirestoreClient @@ -43,6 +44,7 @@ func NewHub( messageRepo *repositories.MessageRepository, roomRepo *repositories.RoomRepository, directChatRepo *repositories.DirectChatRepository, + reportRepo *repositories.ReportRepository, client *config.FirestoreClient, ) *Hub { return &Hub{ @@ -54,6 +56,7 @@ func NewHub( messageRepo: messageRepo, roomRepo: roomRepo, directChatRepo: directChatRepo, + reportRepo: reportRepo, firestoreClient: client, } } diff --git a/internal/pkg/websocket/websocket.go b/internal/pkg/websocket/websocket.go index bf91d2e..69c05b2 100644 --- a/internal/pkg/websocket/websocket.go +++ b/internal/pkg/websocket/websocket.go @@ -103,9 +103,7 @@ func (c *Client) ReadPump() { if err := json.Unmarshal(wsMessage.Payload, &chatMsg); err != nil { log.Printf("Error unmarshaling chat message: %v", err) continue - } - - // Verificar si el usuario es parte de la sala antes de enviar el mensaje + } // Verificar si el usuario es parte de la sala antes de enviar el mensaje if !c.hub.roomRepo.CanTalkInRoomWebSocket(chatMsg.RoomID, c.userID) { errMsg := "No permission to send messages to this room" errorPayload, _ := json.Marshal(errMsg) @@ -118,6 +116,22 @@ func (c *Client) ReadPump() { continue } + // Check if the user is banned from sending messages due to reports + room, err := c.hub.roomRepo.GetRoom(chatMsg.RoomID) + if err == nil && room.ReportedUsers != nil { + if reportCount, exists := room.ReportedUsers[c.userID]; exists && reportCount >= 3 { + errMsg := "You have been banned from sending messages in this room due to reports" + errorPayload, _ := json.Marshal(errMsg) + c.send <- WebSocketMessage{ + Type: MessageTypeError, + Payload: errorPayload, + Timestamp: time.Now(), + } + log.Printf("User %s attempted to send message to room %s while banned", c.userID, chatMsg.RoomID) + continue + } + } + // Asignar ID y timestamps si no existen if chatMsg.ID == "" { chatMsg.ID = uuid.New().String() diff --git a/internal/repositories/message_repository.go b/internal/repositories/message_repository.go index c50c6f4..372658d 100644 --- a/internal/repositories/message_repository.go +++ b/internal/repositories/message_repository.go @@ -57,6 +57,28 @@ func (r *MessageRepository) SaveDirectMessage(message *models.Message) error { return nil } +// GetMessageByID retrieves a message by its ID from a specific room +func (r *MessageRepository) GetMessageByID(roomID, messageID string) (*models.Message, error) { + ctx := context.Background() + + // Get the message from the room's messages collection + doc, err := r.FirestoreClient.Client. + Collection("rooms").Doc(roomID). + Collection("messages").Doc(messageID). + Get(ctx) + + if err != nil { + return nil, fmt.Errorf("error getting message: %v", err) + } + + var message models.Message + if err := doc.DataTo(&message); err != nil { + return nil, fmt.Errorf("error converting document to message: %v", err) + } + + return &message, nil +} + func (r *MessageRepository) GetRoomMessages(roomID string, limit int, cursor string) ([]models.MessageResponse, string, error) { ctx := context.Background() diff --git a/internal/repositories/report_repository.go b/internal/repositories/report_repository.go new file mode 100644 index 0000000..e91f4cc --- /dev/null +++ b/internal/repositories/report_repository.go @@ -0,0 +1,145 @@ +package repositories + +import ( + "context" + "fmt" + "log" + + "cloud.google.com/go/firestore" + "github.com/Parchat/backend/internal/config" + "github.com/Parchat/backend/internal/models" + "github.com/google/uuid" + "google.golang.org/api/iterator" +) + +// ReportRepository handles database operations for message reports +type ReportRepository struct { + FirestoreClient *config.FirestoreClient +} + +// NewReportRepository creates a new instance of ReportRepository +func NewReportRepository(client *config.FirestoreClient) *ReportRepository { + return &ReportRepository{ + FirestoreClient: client, + } +} + +// CreateReport saves a new report in Firestore +func (r *ReportRepository) CreateReport(report *models.Report) error { + ctx := context.Background() + + // Generate ID if not provided + if report.ID == "" { + report.ID = uuid.New().String() + } + + // Save the report in the reports collection + _, err := r.FirestoreClient.Client. + Collection("reports").Doc(report.ID). + Set(ctx, report) + + if err != nil { + return fmt.Errorf("error creating report: %v", err) + } + + return nil +} + +// GetReportCountForUserInRoom gets the number of reports for a user in a specific room +func (r *ReportRepository) GetReportCountForUserInRoom(roomID, userID string) (int, error) { + ctx := context.Background() + + // Query reports for this user in this room + query := r.FirestoreClient.Client. + Collection("reports"). + Where("roomId", "==", roomID). + Where("reportedId", "==", userID) + + docs, err := query.Documents(ctx).GetAll() + if err != nil { + return 0, fmt.Errorf("error getting reports: %v", err) + } + + return len(docs), nil +} + +// GetReportedUsersInRoom gets all users who have been reported in a room +func (r *ReportRepository) GetReportedUsersInRoom(roomID string) (map[string]int, error) { + ctx := context.Background() + reportedUsers := make(map[string]int) + + // Query all reports for this room + query := r.FirestoreClient.Client. + Collection("reports"). + Where("roomId", "==", roomID) + + iter := query.Documents(ctx) + defer iter.Stop() + + for { + doc, err := iter.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, fmt.Errorf("error iterating reports: %v", err) + } + + var report models.Report + if err := doc.DataTo(&report); err != nil { + log.Printf("Error converting document to report: %v", err) + continue + } + + // Increment report count for this user + reportedUsers[report.ReportedID]++ + } + + return reportedUsers, nil +} + +// DeleteReportsForUserInRoom deletes all reports for a user in a specific room +func (r *ReportRepository) DeleteReportsForUserInRoom(roomID, userID string) error { + ctx := context.Background() + + // Query reports for this user in this room + query := r.FirestoreClient.Client. + Collection("reports"). + Where("roomId", "==", roomID). + Where("reportedId", "==", userID) + + // Get all matching documents + docs, err := query.Documents(ctx).GetAll() + if err != nil { + return fmt.Errorf("error getting reports to delete: %v", err) + } + + // Use a transaction instead of batch (as batch is deprecated) + return r.FirestoreClient.Client.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error { + // Delete each document in the transaction + for _, doc := range docs { + if err := tx.Delete(doc.Ref); err != nil { + return fmt.Errorf("error adding delete operation to transaction: %v", err) + } + } + return nil + }) +} + +// UpdateRoomReportedUsers updates the reported users map in a room +func (r *ReportRepository) UpdateRoomReportedUsers(roomID string, reportedUsers map[string]int) error { + ctx := context.Background() + + // Update the reportedUsers field in the room document + _, err := r.FirestoreClient.Client. + Collection("rooms").Doc(roomID). + Update(ctx, []firestore.Update{ + {Path: "reportedUsers", Value: reportedUsers}, + }) + + if err != nil { + return fmt.Errorf("error updating room reported users: %v", err) + } + + return nil +} diff --git a/internal/routes/router.go b/internal/routes/router.go index 178e23d..e7047c2 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -18,6 +18,7 @@ func NewRouter( chatHandler *handlers.ChatHandler, webSocketHandler *handlers.WebSocketHandler, authMw *authMiddleware.AuthMiddleware, + moderationHandler *handlers.ModerationHandler, ) *chi.Mux { r := chi.NewRouter() @@ -79,7 +80,10 @@ func NewRouter( r.Get("/{roomId}", chatHandler.GetRoom) r.Get("/{roomId}/messages", chatHandler.GetRoomMessagesSimple) r.Get("/{roomId}/messages/paginated", chatHandler.GetRoomMessages) - r.Post("/{roomId}/join", chatHandler.JoinRoom) + r.Post("/{roomId}/join", chatHandler.JoinRoom) // Moderation routes + r.Post("/{roomId}/report", moderationHandler.ReportMessage) + r.Get("/{roomId}/banned-users", moderationHandler.GetBannedUsers) + r.Post("/{roomId}/clear-reports", moderationHandler.ClearUserReports) }) // Rutas de chats directos diff --git a/internal/services/moderation_service.go b/internal/services/moderation_service.go new file mode 100644 index 0000000..859fa4e --- /dev/null +++ b/internal/services/moderation_service.go @@ -0,0 +1,179 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/Parchat/backend/internal/models" + "github.com/Parchat/backend/internal/repositories" + "github.com/google/uuid" +) + +const ( + // MaxReportsBeforeBan is the threshold of reports a user can receive before being banned from a room + MaxReportsBeforeBan = 3 +) + +// ModerationService handles operations related to content moderation and user reports +type ModerationService struct { + reportRepo *repositories.ReportRepository + messageRepo *repositories.MessageRepository + roomRepo *repositories.RoomRepository + userRepo *repositories.UserRepository +} + +// NewModerationService creates a new instance of ModerationService +func NewModerationService( + reportRepo *repositories.ReportRepository, + messageRepo *repositories.MessageRepository, + roomRepo *repositories.RoomRepository, + userRepo *repositories.UserRepository, +) *ModerationService { + return &ModerationService{ + reportRepo: reportRepo, + messageRepo: messageRepo, + roomRepo: roomRepo, + userRepo: userRepo, + } +} + +// ReportMessage handles the reporting of an inappropriate message +func (s *ModerationService) ReportMessage(reporterID, roomID, messageID, reason string) error { + // Validate that the message exists + message, err := s.messageRepo.GetMessageByID(roomID, messageID) + if err != nil { + return fmt.Errorf("message not found: %v", err) + } + + // Don't allow users to report their own messages + if message.UserID == reporterID { + return fmt.Errorf("users cannot report their own messages") + } + + // Create a report record + report := &models.Report{ + ID: uuid.New().String(), + MessageID: messageID, + RoomID: roomID, + ReportedID: message.UserID, // The user who sent the message + ReporterID: reporterID, // The user making the report + Reason: reason, + CreatedAt: time.Now(), + } + + // Save the report + if err := s.reportRepo.CreateReport(report); err != nil { + return fmt.Errorf("failed to create report: %v", err) + } + + // Get current reported users for the room + room, err := s.roomRepo.GetRoom(roomID) + if err != nil { + return fmt.Errorf("room not found: %v", err) + } + + // Initialize reportedUsers map if it doesn't exist + if room.ReportedUsers == nil { + room.ReportedUsers = make(map[string]int) + } + + // Increment report count for the user + room.ReportedUsers[message.UserID]++ + + // Update the room with the new reported users + if err := s.reportRepo.UpdateRoomReportedUsers(roomID, room.ReportedUsers); err != nil { + return fmt.Errorf("failed to update room reported users: %v", err) + } + + return nil +} + +// GetBannedUsersInRoom retrieves all users who have been banned in a room +func (s *ModerationService) GetBannedUsersInRoom(roomID string) (*models.BannedUsersResponse, error) { + // Get the room to check if the room exists and to get the reported users + room, err := s.roomRepo.GetRoom(roomID) + if err != nil { + return nil, fmt.Errorf("room not found: %v", err) + } + + // Initialize response + response := &models.BannedUsersResponse{ + Users: []models.BannedUserResponse{}, + } + + // If no reported users, return empty list + if len(room.ReportedUsers) == 0 { + return response, nil + } + + // Fetch user details for each reported user + for userID, reportCount := range room.ReportedUsers { + // Only include users who have reached or exceeded the threshold + if reportCount >= MaxReportsBeforeBan { + // Get user details + ctx := context.Background() + user, err := s.userRepo.GetUserByID(ctx, userID) + if err != nil { + // Skip this user if we can't get their details + continue + } // Add to response + response.Users = append(response.Users, models.BannedUserResponse{ + UserID: userID, + DisplayName: user.DisplayName, + ReportCount: reportCount, + }) + } + } + + return response, nil +} + +// ClearReportsForUser clears all reports for a specific user in a room +func (s *ModerationService) ClearReportsForUser(roomID, userID string) error { + // Delete the reports from the reports collection + if err := s.reportRepo.DeleteReportsForUserInRoom(roomID, userID); err != nil { + return fmt.Errorf("failed to delete reports: %v", err) + } + + // Get the room to update the reported users map + room, err := s.roomRepo.GetRoom(roomID) + if err != nil { + return fmt.Errorf("room not found: %v", err) + } + + // If no reported users, nothing to do + if room.ReportedUsers == nil { + return nil + } + + // Remove the user from the reported users map + delete(room.ReportedUsers, userID) + + // Update the room with the new reported users + if err := s.reportRepo.UpdateRoomReportedUsers(roomID, room.ReportedUsers); err != nil { + return fmt.Errorf("failed to update room reported users: %v", err) + } + + return nil +} + +// CanUserSendMessageInRoom checks if a user can send messages in a room based on report count +func (s *ModerationService) CanUserSendMessageInRoom(roomID, userID string) bool { + // Get the room + room, err := s.roomRepo.GetRoom(roomID) + if err != nil { + // If we can't get the room, default to allowing the message + return true + } + + // If no reported users or user not in reported users, they can send messages + if room.ReportedUsers == nil { + return true + } + + reportCount, exists := room.ReportedUsers[userID] + + // If the user has fewer reports than the threshold or isn't reported, they can send messages + return !exists || reportCount < MaxReportsBeforeBan +} diff --git a/internal/services/room_service.go b/internal/services/room_service.go index 9dcb7d3..e763b0d 100644 --- a/internal/services/room_service.go +++ b/internal/services/room_service.go @@ -78,6 +78,28 @@ func (s *RoomService) JoinRoom(roomID string, userID string) error { return s.RoomRepo.AddMemberToRoom(roomID, userID) } +// IsUserAdminOrOwner checks if a user is an admin or owner of a room +func (s *RoomService) IsUserAdminOrOwner(roomID, userID string) (bool, error) { + room, err := s.RoomRepo.GetRoom(roomID) + if err != nil { + return false, fmt.Errorf("error getting room: %v", err) + } + + // Check if user is the owner + if room.OwnerID == userID { + return true, nil + } + + // Check if user is an admin + for _, adminID := range room.Admins { + if adminID == userID { + return true, nil + } + } + + return false, nil +} + // Helper para verificar si un slice contiene un valor func contains(slice []string, value string) bool { for _, item := range slice { From 1f49424d28a472fddae34520148305ac8ad78e5b Mon Sep 17 00:00:00 2001 From: Joan Manuel Jaramillo Avila <89425013+LifeRIP@users.noreply.github.com> Date: Sun, 22 Jun 2025 13:28:13 -0500 Subject: [PATCH 11/12] feat: Add success messages and prevent duplicate message reports - Introduces success confirmation messages when users join rooms or direct chats via WebSocket. - Adds a check to prevent users from reporting the same message multiple times. - Also updates moderation logic to enforce the maximum report threshold dynamically. --- internal/pkg/websocket/websocket.go | 20 +++++++++++++++++++- internal/repositories/report_repository.go | 19 +++++++++++++++++++ internal/routes/router.go | 4 +++- internal/services/moderation_service.go | 10 ++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/internal/pkg/websocket/websocket.go b/internal/pkg/websocket/websocket.go index 69c05b2..ae1815d 100644 --- a/internal/pkg/websocket/websocket.go +++ b/internal/pkg/websocket/websocket.go @@ -7,6 +7,7 @@ import ( "time" "github.com/Parchat/backend/internal/models" + "github.com/Parchat/backend/internal/services" "github.com/google/uuid" "github.com/gorilla/websocket" ) @@ -35,6 +36,7 @@ const ( MessageTypeJoinDirectChat MessageType = "JOIN_DIRECT_CHAT" MessageTypeUserLeave MessageType = "USER_LEAVE" MessageTypeError MessageType = "ERROR" + MessageTypeSuccess MessageType = "SUCCESS" MessageTypeRoomCreated MessageType = "ROOM_CREATED" ) @@ -119,7 +121,7 @@ func (c *Client) ReadPump() { // Check if the user is banned from sending messages due to reports room, err := c.hub.roomRepo.GetRoom(chatMsg.RoomID) if err == nil && room.ReportedUsers != nil { - if reportCount, exists := room.ReportedUsers[c.userID]; exists && reportCount >= 3 { + if reportCount, exists := room.ReportedUsers[c.userID]; exists && reportCount >= services.MaxReportsBeforeBan { errMsg := "You have been banned from sending messages in this room due to reports" errorPayload, _ := json.Marshal(errMsg) c.send <- WebSocketMessage{ @@ -257,6 +259,14 @@ func (c *Client) ReadPump() { if c.hub.roomRepo.CanJoinRoomWebSocket(roomID, c.userID) { c.rooms[roomID] = true log.Printf("User %s joined room %s", c.userID, roomID) + // Enviar mensaje de confirmación al usuario + successMsg := "Successfully joined room" + successPayload, _ := json.Marshal(successMsg) + c.send <- WebSocketMessage{ + Type: MessageTypeSuccess, + Payload: successPayload, + Timestamp: time.Now(), + } } else { errMsg := "No permission to join this room" errorPayload, _ := json.Marshal(errMsg) @@ -279,6 +289,14 @@ func (c *Client) ReadPump() { if c.hub.directChatRepo.IsUserInDirectChat(directChatID, c.userID) { c.directChat[directChatID] = true log.Printf("User %s joined direct chat %s", c.userID, directChatID) + // Enviar mensaje de confirmación al usuario + successMsg := "Successfully joined direct chat" + successPayload, _ := json.Marshal(successMsg) + c.send <- WebSocketMessage{ + Type: MessageTypeSuccess, + Payload: successPayload, + Timestamp: time.Now(), + } } else { errMsg := "Not a member of this direct chat" errorPayload, _ := json.Marshal(errMsg) diff --git a/internal/repositories/report_repository.go b/internal/repositories/report_repository.go index e91f4cc..effd54f 100644 --- a/internal/repositories/report_repository.go +++ b/internal/repositories/report_repository.go @@ -126,6 +126,25 @@ func (r *ReportRepository) DeleteReportsForUserInRoom(roomID, userID string) err }) } +// HasUserReportedMessage checks if a user has already reported a specific message +func (r *ReportRepository) HasUserReportedMessage(reporterID, messageID string) (bool, error) { + ctx := context.Background() + + // Query reports for this reporter and message + query := r.FirestoreClient.Client. + Collection("reports"). + Where("reporterId", "==", reporterID). + Where("messageId", "==", messageID) + + // Check if any matching documents exist + docs, err := query.Documents(ctx).GetAll() + if err != nil { + return false, fmt.Errorf("error checking for existing report: %v", err) + } + + return len(docs) > 0, nil +} + // UpdateRoomReportedUsers updates the reported users map in a room func (r *ReportRepository) UpdateRoomReportedUsers(roomID string, reportedUsers map[string]int) error { ctx := context.Background() diff --git a/internal/routes/router.go b/internal/routes/router.go index e7047c2..ebf91d0 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -80,7 +80,9 @@ func NewRouter( r.Get("/{roomId}", chatHandler.GetRoom) r.Get("/{roomId}/messages", chatHandler.GetRoomMessagesSimple) r.Get("/{roomId}/messages/paginated", chatHandler.GetRoomMessages) - r.Post("/{roomId}/join", chatHandler.JoinRoom) // Moderation routes + r.Post("/{roomId}/join", chatHandler.JoinRoom) + + // Moderation routes r.Post("/{roomId}/report", moderationHandler.ReportMessage) r.Get("/{roomId}/banned-users", moderationHandler.GetBannedUsers) r.Post("/{roomId}/clear-reports", moderationHandler.ClearUserReports) diff --git a/internal/services/moderation_service.go b/internal/services/moderation_service.go index 859fa4e..59bae0b 100644 --- a/internal/services/moderation_service.go +++ b/internal/services/moderation_service.go @@ -51,6 +51,16 @@ func (s *ModerationService) ReportMessage(reporterID, roomID, messageID, reason return fmt.Errorf("users cannot report their own messages") } + // Check if user has already reported this message + hasReported, err := s.reportRepo.HasUserReportedMessage(reporterID, messageID) + if err != nil { + return fmt.Errorf("error checking for existing report: %v", err) + } + + if hasReported { + return fmt.Errorf("user has already reported this message") + } + // Create a report record report := &models.Report{ ID: uuid.New().String(), From 19f24570d8a56fb058386991748e1fc93217163b Mon Sep 17 00:00:00 2001 From: Joan Manuel Jaramillo Avila <89425013+LifeRIP@users.noreply.github.com> Date: Mon, 23 Jun 2025 22:23:13 -0500 Subject: [PATCH 12/12] refactor: Comment out success message logic in ReadPump --- internal/pkg/websocket/websocket.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/pkg/websocket/websocket.go b/internal/pkg/websocket/websocket.go index ae1815d..21ce3c7 100644 --- a/internal/pkg/websocket/websocket.go +++ b/internal/pkg/websocket/websocket.go @@ -260,13 +260,13 @@ func (c *Client) ReadPump() { c.rooms[roomID] = true log.Printf("User %s joined room %s", c.userID, roomID) // Enviar mensaje de confirmación al usuario - successMsg := "Successfully joined room" - successPayload, _ := json.Marshal(successMsg) - c.send <- WebSocketMessage{ - Type: MessageTypeSuccess, - Payload: successPayload, - Timestamp: time.Now(), - } + // successMsg := "Successfully joined room" + // successPayload, _ := json.Marshal(successMsg) + // c.send <- WebSocketMessage{ + // Type: MessageTypeSuccess, + // Payload: successPayload, + // Timestamp: time.Now(), + // } } else { errMsg := "No permission to join this room" errorPayload, _ := json.Marshal(errMsg) @@ -290,13 +290,13 @@ func (c *Client) ReadPump() { c.directChat[directChatID] = true log.Printf("User %s joined direct chat %s", c.userID, directChatID) // Enviar mensaje de confirmación al usuario - successMsg := "Successfully joined direct chat" - successPayload, _ := json.Marshal(successMsg) - c.send <- WebSocketMessage{ - Type: MessageTypeSuccess, - Payload: successPayload, - Timestamp: time.Now(), - } + // successMsg := "Successfully joined direct chat" + // successPayload, _ := json.Marshal(successMsg) + // c.send <- WebSocketMessage{ + // Type: MessageTypeSuccess, + // Payload: successPayload, + // Timestamp: time.Now(), + // } } else { errMsg := "Not a member of this direct chat" errorPayload, _ := json.Marshal(errMsg)