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 1/2] 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 2/2] 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(),