package handlers import ( "context" "errors" "net/http" "strconv" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/services" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // RoomServiceInterface defines the interface for room service operations type RoomServiceInterface interface { CreateRoom(ctx context.Context, userID uuid.UUID, req services.CreateRoomRequest) (*services.RoomResponse, error) GetUserRooms(ctx context.Context, userID uuid.UUID) ([]*services.RoomResponse, error) GetRoom(ctx context.Context, roomID uuid.UUID) (*services.RoomResponse, error) IsRoomMember(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) (bool, error) UpdateRoom(ctx context.Context, roomID uuid.UUID, userID uuid.UUID, req services.UpdateRoomRequest) (*services.RoomResponse, error) // BE-API-012: Update room method AddMember(ctx context.Context, roomID, userID uuid.UUID) error RemoveMember(ctx context.Context, roomID, userID uuid.UUID) error // BE-API-011: Remove member method GetRoomHistory(ctx context.Context, roomID uuid.UUID, limit, offset int) ([]services.ChatMessageResponse, error) GetRoomHistoryWithCursor(ctx context.Context, roomID uuid.UUID, limit int, cursor string) (*services.RoomHistoryWithCursorResult, error) // v0.931: cursor pagination DeleteRoom(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error // BE-API-010: Delete room method GetRoomMembers(ctx context.Context, roomID uuid.UUID, requestUserID uuid.UUID) (*services.RoomMembersResponse, error) // v0.9.6 @mention, v0.9.7 roles CreateInvitation(ctx context.Context, roomID uuid.UUID, inviterID uuid.UUID) (*services.RoomInvitationResponse, error) // v0.9.7 JoinByToken(ctx context.Context, token uuid.UUID, userID uuid.UUID) (uuid.UUID, error) // v0.9.7 KickMember(ctx context.Context, roomID uuid.UUID, targetUserID uuid.UUID, requestUserID uuid.UUID) error // v0.9.7 UpdateMemberRole(ctx context.Context, roomID, targetUserID, requestUserID uuid.UUID, newRole string) error // v0.9.7 } // RoomHandler gère les opérations sur les rooms (conversations) type RoomHandler struct { roomService RoomServiceInterface logger *zap.Logger commonHandler *CommonHandler } // NewRoomHandler crée une nouvelle instance de RoomHandler func NewRoomHandler(roomService RoomServiceInterface, logger *zap.Logger) *RoomHandler { return &RoomHandler{ roomService: roomService, logger: logger, commonHandler: NewCommonHandler(logger), } } // CreateRoom gère la création d'une nouvelle room // POST /api/v1/conversations func (h *RoomHandler) CreateRoom(c *gin.Context) { // Récupérer l'ID utilisateur du contexte userIDInterface, exists := c.Get("user_id") if !exists { RespondWithAppError(c, apperrors.NewUnauthorizedError("User not authenticated")) return } // Convertir userID en uuid.UUID userID, ok := userIDInterface.(uuid.UUID) if !ok { RespondWithAppError(c, apperrors.NewInternalError("Invalid user ID type in context")) return } // Parser la requête var req services.CreateRoomRequest if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil { RespondWithAppError(c, appErr) return } // Valider le type de room si non spécifié if req.Type == "" { req.Type = "public" } // Créer la room room, err := h.roomService.CreateRoom(c.Request.Context(), userID, req) if err != nil { h.logger.Error("failed to create room", zap.Error(err), zap.String("user_id", userID.String()), zap.String("room_name", req.Name)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to create conversation", err)) return } h.logger.Info("room created successfully", zap.String("room_id", room.ID.String()), zap.String("user_id", userID.String()), zap.String("room_name", req.Name)) RespondSuccess(c, http.StatusCreated, room) } // GetUserRooms récupère toutes les rooms d'un utilisateur // GET /api/v1/conversations func (h *RoomHandler) GetUserRooms(c *gin.Context) { // Récupérer l'ID utilisateur du contexte userIDInterface, exists := c.Get("user_id") if !exists { RespondWithAppError(c, apperrors.NewUnauthorizedError("User not authenticated")) return } // Convertir userID en uuid.UUID userID, ok := userIDInterface.(uuid.UUID) if !ok { RespondWithAppError(c, apperrors.NewInternalError("Invalid user ID type in context")) return } // Récupérer les rooms rooms, err := h.roomService.GetUserRooms(c.Request.Context(), userID) if err != nil { h.logger.Error("failed to get user rooms", zap.Error(err), zap.String("user_id", userID.String())) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to fetch conversations", err)) return } RespondSuccess(c, http.StatusOK, gin.H{ "conversations": rooms, "total": len(rooms), }) } // GetRoom récupère une room par son ID // GET /api/v1/conversations/:id // SECURITY(CRIT-001): Verify membership before returning room data func (h *RoomHandler) GetRoom(c *gin.Context) { // Récupérer l'ID de la room depuis l'URL roomIDStr := c.Param("id") roomID, err := uuid.Parse(roomIDStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid room ID")) return } // SECURITY(CRIT-001): Verify the requesting user is a member of this room userID, ok := GetUserIDUUID(c) if !ok { RespondWithAppError(c, apperrors.NewUnauthorizedError("authentication required")) return } isMember, err := h.roomService.IsRoomMember(c.Request.Context(), roomID, userID) if err != nil { if errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("Conversation")) return } h.logger.Error("failed to check room membership", zap.Error(err), zap.String("room_id", roomID.String())) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get conversation", err)) return } if !isMember { RespondWithAppError(c, apperrors.NewNotFoundError("Conversation")) return } // Récupérer la room room, err := h.roomService.GetRoom(c.Request.Context(), roomID) if err != nil { if errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("Conversation")) return } h.logger.Error("failed to get room", zap.Error(err), zap.String("room_id", roomID.String())) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get conversation", err)) return } RespondSuccess(c, http.StatusOK, room) } // UpdateRoom met à jour une room (conversation) // PUT /api/v1/conversations/:id // BE-API-012: Implement conversation update endpoint func (h *RoomHandler) UpdateRoom(c *gin.Context) { // Récupérer l'ID de la room depuis l'URL roomIDStr := c.Param("id") roomID, err := uuid.Parse(roomIDStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid room id")) return } // Récupérer l'ID utilisateur du contexte userID, ok := GetUserIDUUID(c) if !ok { return // Erreur déjà envoyée par GetUserIDUUID } // Parser la requête var req services.UpdateRoomRequest if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil { RespondWithAppError(c, appErr) return } // Mettre à jour la room room, err := h.roomService.UpdateRoom(c.Request.Context(), roomID, userID, req) if err != nil { if err.Error() == "room not found" || errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("conversation")) return } if err.Error() == "forbidden: only room creator can update the room" { RespondWithAppError(c, apperrors.NewForbiddenError("only room creator can update the room")) return } h.logger.Error("failed to update room", zap.Error(err), zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to update conversation", err)) return } h.logger.Info("room updated successfully", zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) RespondSuccess(c, http.StatusOK, room) } // AddMemberRequest représente une requête pour ajouter un membre à une room // MOD-P1-001: Ajout tags validate pour validation systématique type AddMemberRequest struct { UserID uuid.UUID `json:"user_id" binding:"required" validate:"required"` // Changed to UUID } // AddMember ajoute un membre à une room // POST /api/v1/conversations/:id/members func (h *RoomHandler) AddMember(c *gin.Context) { // Récupérer l'ID de la room depuis l'URL roomIDStr := c.Param("id") roomID, err := uuid.Parse(roomIDStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid room ID")) return } // Parser la requête var req AddMemberRequest if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil { RespondWithAppError(c, appErr) return } // Ajouter le membre if err := h.roomService.AddMember(c.Request.Context(), roomID, req.UserID); err != nil { h.logger.Error("failed to add member to room", zap.Error(err), zap.String("room_id", roomID.String()), zap.String("user_id", req.UserID.String())) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to add member", err)) return } h.logger.Info("member added to room", zap.String("room_id", roomID.String()), zap.String("user_id", req.UserID.String())) RespondSuccess(c, http.StatusOK, gin.H{"message": "Member added successfully"}) } // GetRoomHistory récupère l'historique des messages d'une room // GET /api/v1/conversations/:id/history // v0.931: Supports cursor-based pagination via ?cursor=xxx&limit=20. Falls back to offset when cursor not provided. // SECURITY(CRIT-001): Verify membership before returning history func (h *RoomHandler) GetRoomHistory(c *gin.Context) { conversationIDStr := c.Param("id") conversationID, err := uuid.Parse(conversationIDStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid conversation ID")) return } // SECURITY(CRIT-001): Verify the requesting user is a member of this room userID, ok := GetUserIDUUID(c) if !ok { RespondWithAppError(c, apperrors.NewUnauthorizedError("authentication required")) return } isMember, err := h.roomService.IsRoomMember(c.Request.Context(), conversationID, userID) if err != nil { if errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("Conversation")) return } h.logger.Error("failed to check room membership", zap.Error(err), zap.String("conversation_id", conversationID.String())) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get conversation history", err)) return } if !isMember { RespondWithAppError(c, apperrors.NewNotFoundError("Conversation")) return } limit := c.DefaultQuery("limit", "50") limitInt, err := strconv.Atoi(limit) if err != nil || limitInt <= 0 { limitInt = 50 } if limitInt > 100 { limitInt = 100 } cursor := c.Query("cursor") if cursor != "" { result, err := h.roomService.GetRoomHistoryWithCursor(c.Request.Context(), conversationID, limitInt, cursor) if err != nil { if errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("Conversation")) return } h.logger.Error("failed to get room history", zap.Error(err), zap.String("conversation_id", conversationID.String())) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get conversation history", err)) return } resp := gin.H{"messages": result.Messages} if result.NextCursor != "" { resp["next_cursor"] = result.NextCursor } RespondSuccess(c, http.StatusOK, resp) return } offset := c.DefaultQuery("offset", "0") offsetInt, err := strconv.Atoi(offset) if err != nil || offsetInt < 0 { offsetInt = 0 } messages, err := h.roomService.GetRoomHistory(c.Request.Context(), conversationID, limitInt, offsetInt) if err != nil { if errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("Conversation")) return } h.logger.Error("failed to get room history", zap.Error(err), zap.String("conversation_id", conversationID.String())) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get conversation history", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"messages": messages}) } // DeleteRoom supprime une room (conversation) // DELETE /api/v1/conversations/:id // BE-API-010: Implement conversation delete endpoint func (h *RoomHandler) DeleteRoom(c *gin.Context) { // Récupérer l'ID de la room depuis l'URL roomIDStr := c.Param("id") roomID, err := uuid.Parse(roomIDStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid room id")) return } // Récupérer l'ID utilisateur du contexte userID, ok := GetUserIDUUID(c) if !ok { return // Erreur déjà envoyée par GetUserIDUUID } // Supprimer la room if err := h.roomService.DeleteRoom(c.Request.Context(), roomID, userID); err != nil { if err.Error() == "room not found" || errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("conversation")) return } if err.Error() == "forbidden: only room creator can delete the room" { RespondWithAppError(c, apperrors.NewForbiddenError("only room creator can delete the room")) return } h.logger.Error("failed to delete room", zap.Error(err), zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to delete conversation", err)) return } h.logger.Info("room deleted successfully", zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) RespondSuccess(c, http.StatusOK, gin.H{"message": "Conversation deleted successfully"}) } // AddParticipant ajoute un participant à une conversation // POST /api/v1/conversations/:id/participants // BE-API-011: Implement conversation participants endpoints func (h *RoomHandler) AddParticipant(c *gin.Context) { // Récupérer l'ID de la room depuis l'URL roomIDStr := c.Param("id") roomID, err := uuid.Parse(roomIDStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid room id")) return } // Parser la requête var req AddMemberRequest if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil { RespondWithAppError(c, appErr) return } // Ajouter le participant (utilise la même logique que AddMember) if err := h.roomService.AddMember(c.Request.Context(), roomID, req.UserID); err != nil { if errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("conversation")) return } h.logger.Error("failed to add participant to room", zap.Error(err), zap.String("room_id", roomID.String()), zap.String("user_id", req.UserID.String())) RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to add participant", err)) return } h.logger.Info("participant added to room", zap.String("room_id", roomID.String()), zap.String("user_id", req.UserID.String())) RespondSuccess(c, http.StatusOK, gin.H{"message": "Participant added successfully"}) } // RemoveParticipant retire un participant d'une conversation // DELETE /api/v1/conversations/:id/participants/:userId // BE-API-011: Implement conversation participants endpoints func (h *RoomHandler) RemoveParticipant(c *gin.Context) { // Récupérer l'ID de la room depuis l'URL roomIDStr := c.Param("id") roomID, err := uuid.Parse(roomIDStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid room id")) return } // Récupérer l'ID de l'utilisateur depuis l'URL userIDStr := c.Param("userId") userID, err := uuid.Parse(userIDStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid user id")) return } // Retirer le participant if err := h.roomService.RemoveMember(c.Request.Context(), roomID, userID); err != nil { if errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("conversation")) return } h.logger.Error("failed to remove participant from room", zap.Error(err), zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to remove participant", err)) return } h.logger.Info("participant removed from room", zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) RespondSuccess(c, http.StatusOK, gin.H{"message": "Participant removed successfully"}) } // CreateInvitation creates a room invitation (v0.9.7) // POST /api/v1/conversations/:id/invitations func (h *RoomHandler) CreateInvitation(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } roomID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid room ID")) return } resp, err := h.roomService.CreateInvitation(c.Request.Context(), roomID, userID) if err != nil { if err.Error() == "forbidden: only owner or admin can create invitations" { RespondWithAppError(c, apperrors.NewForbiddenError(err.Error())) return } if errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("Conversation")) return } h.logger.Error("failed to create invitation", zap.Error(err), zap.String("room_id", roomID.String())) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to create invitation", err)) return } RespondSuccess(c, http.StatusCreated, resp) } // JoinByToken joins a room via invitation token (v0.9.7) // GET /api/v1/conversations/join/:token func (h *RoomHandler) JoinByToken(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } token, err := uuid.Parse(c.Param("token")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid token")) return } roomID, err := h.roomService.JoinByToken(c.Request.Context(), token, userID) if err != nil { if err.Error() == "invitation not found or expired" { RespondWithAppError(c, apperrors.NewNotFoundError(err.Error())) return } h.logger.Error("failed to join via token", zap.Error(err), zap.String("token", token.String())) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to join", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"room_id": roomID}) } // KickMember removes a member (owner/admin only) (v0.9.7) // DELETE /api/v1/conversations/:id/members/:userId func (h *RoomHandler) KickMember(c *gin.Context) { requestUserID, ok := GetUserIDUUID(c) if !ok { return } roomID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid room ID")) return } targetUserID, err := uuid.Parse(c.Param("userId")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid user ID")) return } if err := h.roomService.KickMember(c.Request.Context(), roomID, targetUserID, requestUserID); err != nil { if err.Error() == "forbidden: only owner or admin can remove members" { RespondWithAppError(c, apperrors.NewForbiddenError(err.Error())) return } if errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("Conversation")) return } h.logger.Error("failed to kick member", zap.Error(err), zap.String("room_id", roomID.String())) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to remove member", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"message": "Member removed successfully"}) } // LeaveRoom allows a user to leave a conversation (self-removal) (v0.9.7) // POST /api/v1/conversations/:id/leave func (h *RoomHandler) LeaveRoom(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } roomID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid conversation ID")) return } if err := h.roomService.RemoveMember(c.Request.Context(), roomID, userID); err != nil { if errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("Conversation")) return } h.logger.Error("failed to leave room", zap.Error(err), zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to leave conversation", err)) return } h.logger.Info("user left room", zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) RespondSuccess(c, http.StatusOK, gin.H{"message": "Left conversation successfully"}) } // UpdateMemberRole updates a member's role (v0.9.7). Owner only. // PATCH /api/v1/conversations/:id/members/:userId func (h *RoomHandler) UpdateMemberRole(c *gin.Context) { requestUserID, ok := GetUserIDUUID(c) if !ok { return } roomID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid conversation ID")) return } targetUserID, err := uuid.Parse(c.Param("userId")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid user ID")) return } var req services.UpdateMemberRoleRequest if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil { RespondWithAppError(c, appErr) return } if err := h.roomService.UpdateMemberRole(c.Request.Context(), roomID, targetUserID, requestUserID, req.Role); err != nil { if err.Error() == "forbidden: only owner can change member roles" || err.Error() == "forbidden: cannot change owner role" || err.Error() == "forbidden: owner cannot change their own role" || err.Error() == "forbidden: cannot promote to owner via this endpoint" { RespondWithAppError(c, apperrors.NewForbiddenError(err.Error())) return } if errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("Conversation or member")) return } h.logger.Error("failed to update member role", zap.Error(err)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to update member role", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"message": "Member role updated successfully"}) } // GetMembers returns room members for @mention autocomplete (v0.9.6) // GET /api/v1/conversations/:id/members func (h *RoomHandler) GetMembers(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } roomIDStr := c.Param("id") roomID, err := uuid.Parse(roomIDStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid room ID")) return } resp, err := h.roomService.GetRoomMembers(c.Request.Context(), roomID, userID) if err != nil { if errors.Is(err, services.ErrRoomNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("Conversation")) return } h.logger.Error("failed to get room members", zap.Error(err), zap.String("room_id", roomID.String())) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get members", err)) return } RespondSuccess(c, http.StatusOK, resp) }