package services import ( "context" "encoding/json" "errors" "fmt" "time" "veza-backend-api/internal/models" "veza-backend-api/internal/repositories" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" ) // RoomService gère la logique métier pour les rooms type RoomService struct { roomRepo *repositories.RoomRepository messageRepo *repositories.ChatMessageRepository logger *zap.Logger } // NewRoomService crée une nouvelle instance de RoomService func NewRoomService(roomRepo *repositories.RoomRepository, messageRepo *repositories.ChatMessageRepository, logger *zap.Logger) *RoomService { return &RoomService{ roomRepo: roomRepo, messageRepo: messageRepo, logger: logger, } } // CreateRoomRequest représente une requête de création de room type CreateRoomRequest struct { Name string `json:"name" binding:"required,min=1,max=255"` Description *string `json:"description,omitempty"` Type string `json:"type" binding:"required,oneof=public private direct collaborative"` IsPrivate bool `json:"is_private"` } // RoomResponse représente une réponse de room pour l'API // MIGRATION UUID: CreatedBy et Participants migrés vers UUID type RoomResponse struct { ID uuid.UUID `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` Type string `json:"type"` IsPrivate bool `json:"is_private"` CreatedBy *uuid.UUID `json:"created_by"` Participants []uuid.UUID `json:"participants"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // CreateRoom crée une nouvelle room func (s *RoomService) CreateRoom(ctx context.Context, userID uuid.UUID, req CreateRoomRequest) (*RoomResponse, error) { if req.Name == "" { return nil, errors.New("room name is required") } // Créer la room isPrivate := req.IsPrivate if req.Type == "collaborative" { isPrivate = true // v0.10.7 F483: collaborative rooms are invite-only } room := &models.Room{ Name: req.Name, Description: "", Type: req.Type, IsPrivate: isPrivate, CreatedBy: userID, } if req.Description != nil { room.Description = *req.Description } if err := s.roomRepo.Create(ctx, room); err != nil { s.logger.Error("failed to create room", zap.Error(err), zap.String("user_id", userID.String()), zap.String("room_name", req.Name)) return nil, fmt.Errorf("failed to create room: %w", err) } // Ajouter le créateur comme owner member := &models.RoomMember{ RoomID: room.ID, UserID: userID, Role: "owner", } if err := s.roomRepo.AddMember(ctx, member); err != nil { s.logger.Error("failed to add creator as room member", zap.Error(err), zap.String("room_id", room.ID.String()), zap.String("user_id", userID.String())) // Ne pas retourner d'erreur, la room est créée } s.logger.Info("room created successfully", zap.String("room_id", room.ID.String()), zap.String("user_id", userID.String()), zap.String("room_name", room.Name)) return &RoomResponse{ ID: room.ID, Name: room.Name, Description: room.Description, Type: room.Type, IsPrivate: room.IsPrivate, CreatedBy: &room.CreatedBy, // Corrected: & to get pointer to uuid.UUID Participants: []uuid.UUID{userID}, CreatedAt: room.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: room.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), }, nil } // GetUserRooms récupère toutes les rooms d'un utilisateur func (s *RoomService) GetUserRooms(ctx context.Context, userID uuid.UUID) ([]*RoomResponse, error) { rooms, err := s.roomRepo.GetByUserID(ctx, userID) if err != nil { s.logger.Error("failed to get user rooms", zap.Error(err), zap.String("user_id", userID.String())) return nil, fmt.Errorf("failed to get user rooms: %w", err) } responses := make([]*RoomResponse, 0, len(rooms)) for _, room := range rooms { // Récupérer les membres pour avoir la liste des participants members, err := s.roomRepo.GetMembersByRoomID(ctx, room.ID) if err != nil { s.logger.Warn("failed to get room members", zap.Error(err), zap.String("room_id", room.ID.String())) members = []*models.RoomMember{} } participants := make([]uuid.UUID, 0, len(members)) for _, member := range members { participants = append(participants, member.UserID) } responses = append(responses, &RoomResponse{ ID: room.ID, Name: room.Name, Description: room.Description, Type: room.Type, IsPrivate: room.IsPrivate, CreatedBy: &room.CreatedBy, // Corrected: & to get pointer to uuid.UUID Participants: participants, CreatedAt: room.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: room.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), }) } return responses, nil } // GetRoom récupère une room par son ID func (s *RoomService) GetRoom(ctx context.Context, roomID uuid.UUID) (*RoomResponse, error) { room, err := s.roomRepo.GetByID(ctx, roomID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrRoomNotFound } s.logger.Error("failed to get room", zap.Error(err), zap.String("room_id", roomID.String())) return nil, fmt.Errorf("failed to get room: %w", err) } // Récupérer les membres members, err := s.roomRepo.GetMembersByRoomID(ctx, roomID) if err != nil { s.logger.Warn("failed to get room members", zap.Error(err), zap.String("room_id", roomID.String())) members = []*models.RoomMember{} } participants := make([]uuid.UUID, 0, len(members)) for _, member := range members { participants = append(participants, member.UserID) } return &RoomResponse{ ID: room.ID, Name: room.Name, Description: room.Description, Type: room.Type, IsPrivate: room.IsPrivate, CreatedBy: &room.CreatedBy, // Corrected: & to get pointer to uuid.UUID Participants: participants, CreatedAt: room.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: room.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), }, nil } // IsRoomMember checks if a user is a member of a room. // SECURITY(CRIT-001): Used to prevent unauthorized access to private conversations. func (s *RoomService) IsRoomMember(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) (bool, error) { members, err := s.roomRepo.GetMembersByRoomID(ctx, roomID) if err != nil { return false, fmt.Errorf("failed to check membership: %w", err) } for _, m := range members { if m.UserID == userID { return true, nil } } return false, nil } // MaxCollaborativeMembers v0.10.7 F483 const MaxCollaborativeMembers = 10 // AddMember ajoute un membre à une room func (s *RoomService) AddMember(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error { // v0.10.7 F483: collaborative rooms max 10 participants room, err := s.roomRepo.GetByID(ctx, roomID) if err == nil && room.Type == "collaborative" { count, _ := s.roomRepo.CountMembers(ctx, roomID) if count >= MaxCollaborativeMembers { return errors.New("collaborative room has reached maximum of 10 participants") } } member := &models.RoomMember{ RoomID: roomID, UserID: userID, Role: "member", } if err := s.roomRepo.AddMember(ctx, member); err != nil { s.logger.Error("failed to add member to room", zap.Error(err), zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) return fmt.Errorf("failed to add member: %w", err) } s.logger.Info("member added to room", zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) return nil } // KickMember removes a member from a room. Only owner or admin can kick. (v0.9.7) func (s *RoomService) KickMember(ctx context.Context, roomID uuid.UUID, targetUserID uuid.UUID, requestUserID uuid.UUID) error { role, err := s.roomRepo.GetMemberRole(ctx, roomID, requestUserID) if err != nil { return fmt.Errorf("failed to get requester role: %w", err) } if role == "" { return ErrRoomNotFound } if role != "owner" && role != "admin" { return errors.New("forbidden: only owner or admin can remove members") } return s.RemoveMember(ctx, roomID, targetUserID) } // CreateInvitation creates a room invitation (link-based, expires 7 days). Inviter must be owner/admin. func (s *RoomService) CreateInvitation(ctx context.Context, roomID uuid.UUID, inviterID uuid.UUID) (*RoomInvitationResponse, error) { role, err := s.roomRepo.GetMemberRole(ctx, roomID, inviterID) if err != nil { return nil, fmt.Errorf("failed to get inviter role: %w", err) } if role == "" { return nil, ErrRoomNotFound } if role != "owner" && role != "admin" { return nil, errors.New("forbidden: only owner or admin can create invitations") } inv := &models.RoomInvitation{ RoomID: roomID, InviterID: inviterID, Status: "pending", ExpiresAt: time.Now().Add(7 * 24 * time.Hour), } if err := s.roomRepo.CreateRoomInvitation(ctx, inv); err != nil { return nil, fmt.Errorf("failed to create invitation: %w", err) } return &RoomInvitationResponse{ Token: inv.Token.String(), InviteURL: "/chat/join/" + inv.Token.String(), ExpiresAt: inv.ExpiresAt, }, nil } // RoomInvitationResponse for CreateInvitation type RoomInvitationResponse struct { Token string `json:"token"` InviteURL string `json:"invite_url"` ExpiresAt time.Time `json:"expires_at"` } // JoinByToken joins a room using an invitation token. Returns room ID. func (s *RoomService) JoinByToken(ctx context.Context, token uuid.UUID, userID uuid.UUID) (uuid.UUID, error) { inv, err := s.roomRepo.GetRoomInvitationByToken(ctx, token) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return uuid.Nil, errors.New("invitation not found or expired") } return uuid.Nil, fmt.Errorf("failed to get invitation: %w", err) } // Add user as member if err := s.AddMember(ctx, inv.RoomID, userID); err != nil { return uuid.Nil, fmt.Errorf("failed to join room: %w", err) } _ = s.roomRepo.UpdateRoomInvitationStatus(ctx, inv.ID, "accepted") return inv.RoomID, nil } // RemoveMember retire un membre d'une room // BE-API-011: Implement conversation participants endpoints func (s *RoomService) RemoveMember(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error { // Vérifier que la room existe _, err := s.roomRepo.GetByID(ctx, roomID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrRoomNotFound } return fmt.Errorf("failed to get room: %w", err) } // Retirer le membre if err := s.roomRepo.RemoveMember(ctx, roomID, userID); err != nil { s.logger.Error("failed to remove member from room", zap.Error(err), zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) return fmt.Errorf("failed to remove member: %w", err) } s.logger.Info("member removed from room", zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) return nil } // UpdateRoomRequest représente une requête de mise à jour de room // BE-API-012: Implement conversation update endpoint type UpdateRoomRequest struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` } // UpdateRoom met à jour une room (nom et/ou description) // BE-API-012: Implement conversation update endpoint // Seul le créateur de la room ou un admin peut mettre à jour la room func (s *RoomService) UpdateRoom(ctx context.Context, roomID uuid.UUID, userID uuid.UUID, req UpdateRoomRequest) (*RoomResponse, error) { // Vérifier que la room existe room, err := s.roomRepo.GetByID(ctx, roomID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrRoomNotFound } return nil, fmt.Errorf("failed to get room: %w", err) } // Vérifier que l'utilisateur est le créateur de la room if room.CreatedBy != userID { return nil, fmt.Errorf("forbidden: only room creator can update the room") } // Mettre à jour les champs fournis if req.Name != nil { room.Name = *req.Name } if req.Description != nil { room.Description = *req.Description } // Sauvegarder les modifications if err := s.roomRepo.Update(ctx, room); err != nil { s.logger.Error("failed to update room", zap.Error(err), zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) return nil, fmt.Errorf("failed to update room: %w", err) } // Récupérer les membres pour la réponse members, err := s.roomRepo.GetMembersByRoomID(ctx, roomID) if err != nil { s.logger.Warn("failed to get room members", zap.Error(err), zap.String("room_id", roomID.String())) members = []*models.RoomMember{} } participants := make([]uuid.UUID, 0, len(members)) for _, member := range members { participants = append(participants, member.UserID) } s.logger.Info("room updated successfully", zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) return &RoomResponse{ ID: room.ID, Name: room.Name, Description: room.Description, Type: room.Type, IsPrivate: room.IsPrivate, CreatedBy: &room.CreatedBy, Participants: participants, CreatedAt: room.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: room.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), }, nil } // MessageAttachmentResponse for history (v0.9.7) type MessageAttachmentResponse struct { FileURL string `json:"file_url"` FileName string `json:"file_name"` FileType string `json:"file_type"` FileSize int64 `json:"file_size,omitempty"` } // ChatMessageResponse pour la réponse d'historique (v0.9.7: SenderUsername, Attachments) type ChatMessageResponse struct { ID uuid.UUID `json:"id"` ConversationID uuid.UUID `json:"conversation_id"` SenderID uuid.UUID `json:"sender_id"` SenderUsername string `json:"sender_username,omitempty"` Content string `json:"content"` MessageType string `json:"message_type"` ParentMessageID *uuid.UUID `json:"parent_message_id,omitempty"` Attachments []MessageAttachmentResponse `json:"attachments,omitempty"` CreatedAt time.Time `json:"created_at"` } // parseAttachmentsFromMetadata extracts attachments from message metadata JSONB func parseAttachmentsFromMetadata(metadata []byte) []MessageAttachmentResponse { if len(metadata) == 0 { return nil } var m map[string]interface{} if err := json.Unmarshal(metadata, &m); err != nil { return nil } raw, ok := m["attachments"] if !ok || raw == nil { return nil } arr, ok := raw.([]interface{}) if !ok || len(arr) == 0 { return nil } out := make([]MessageAttachmentResponse, 0, len(arr)) for _, v := range arr { obj, ok := v.(map[string]interface{}) if !ok { continue } a := MessageAttachmentResponse{} if s, ok := obj["file_url"].(string); ok { a.FileURL = s } if s, ok := obj["file_name"].(string); ok { a.FileName = s } if s, ok := obj["file_type"].(string); ok { a.FileType = s } if n, ok := obj["file_size"].(float64); ok { a.FileSize = int64(n) } out = append(out, a) } return out } // GetRoomHistory récupère l'historique des messages d'une room func (s *RoomService) GetRoomHistory(ctx context.Context, roomID uuid.UUID, limit, offset int) ([]ChatMessageResponse, error) { messages, err := s.messageRepo.GetConversationMessages(ctx, roomID, limit, offset) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) || err.Error() == "conversation not found" { // Check if room exists first? Assuming Repo handles it or we could use GetRoom logic // If messageRepo returns error on room not found return nil, ErrRoomNotFound } s.logger.Error("failed to get room history", zap.Error(err), zap.String("room_id", roomID.String())) return nil, fmt.Errorf("failed to get room history: %w", err) } responses := make([]ChatMessageResponse, len(messages)) for i, msg := range messages { senderUsername := "" if msg.Sender != nil { senderUsername = msg.Sender.Username } responses[i] = ChatMessageResponse{ ID: msg.ID, ConversationID: msg.ConversationID, SenderID: msg.SenderID, SenderUsername: senderUsername, Content: msg.Content, MessageType: msg.MessageType, ParentMessageID: msg.ReplyToID, Attachments: parseAttachmentsFromMetadata(msg.Metadata), CreatedAt: msg.CreatedAt, } } return responses, nil } // RoomHistoryWithCursorResult holds messages and next cursor for cursor-based pagination (v0.931). type RoomHistoryWithCursorResult struct { Messages []ChatMessageResponse NextCursor string } // GetRoomHistoryWithCursor uses keyset pagination on (created_at, id) for consistent performance. func (s *RoomService) GetRoomHistoryWithCursor(ctx context.Context, roomID uuid.UUID, limit int, cursor string) (*RoomHistoryWithCursorResult, error) { result, err := s.messageRepo.GetConversationMessagesWithCursor(ctx, roomID, limit, cursor) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) || err.Error() == "conversation not found" { return nil, ErrRoomNotFound } s.logger.Error("failed to get room history with cursor", zap.Error(err), zap.String("room_id", roomID.String())) return nil, fmt.Errorf("failed to get room history: %w", err) } responses := make([]ChatMessageResponse, len(result.Messages)) for i, msg := range result.Messages { senderUsername := "" if msg.Sender != nil { senderUsername = msg.Sender.Username } responses[i] = ChatMessageResponse{ ID: msg.ID, ConversationID: msg.ConversationID, SenderID: msg.SenderID, SenderUsername: senderUsername, Content: msg.Content, MessageType: msg.MessageType, ParentMessageID: msg.ReplyToID, Attachments: parseAttachmentsFromMetadata(msg.Metadata), CreatedAt: msg.CreatedAt, } } return &RoomHistoryWithCursorResult{Messages: responses, NextCursor: result.NextCursor}, nil } // DeleteRoom supprime une room (soft delete) // BE-API-010: Implement conversation delete endpoint // Seul le créateur de la room ou un admin peut supprimer la room func (s *RoomService) DeleteRoom(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error { // Vérifier que la room existe room, err := s.roomRepo.GetByID(ctx, roomID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrRoomNotFound } return fmt.Errorf("failed to get room: %w", err) } // Vérifier que l'utilisateur est le créateur de la room if room.CreatedBy != userID { return fmt.Errorf("forbidden: only room creator can delete the room") } // Supprimer la room (soft delete via GORM) if err := s.roomRepo.Delete(ctx, roomID); err != nil { s.logger.Error("failed to delete room", zap.Error(err), zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) return fmt.Errorf("failed to delete room: %w", err) } s.logger.Info("room deleted successfully", zap.String("room_id", roomID.String()), zap.String("user_id", userID.String())) return nil } // RoomMemberResponse for members list (v0.9.6 @mention, v0.9.7 roles) type RoomMemberResponse struct { UserID uuid.UUID `json:"user_id"` Username string `json:"username"` Role string `json:"role"` } // RoomMembersResponse for GET members (v0.9.7) includes my_role for UI (show kick button) type RoomMembersResponse struct { Members []RoomMemberResponse `json:"members"` MyRole string `json:"my_role"` // owner, admin, member } // UpdateMemberRoleRequest for PATCH members (v0.9.7) type UpdateMemberRoleRequest struct { Role string `json:"role" binding:"required,oneof=admin moderator member"` } // UpdateMemberRole updates a member's role. Only owner can promote to admin. Owner cannot demote themselves. func (s *RoomService) UpdateMemberRole(ctx context.Context, roomID, targetUserID, requestUserID uuid.UUID, newRole string) error { requesterRole, err := s.roomRepo.GetMemberRole(ctx, roomID, requestUserID) if err != nil { return fmt.Errorf("failed to get requester role: %w", err) } if requesterRole == "" { return ErrRoomNotFound } if requesterRole != "owner" { return errors.New("forbidden: only owner can change member roles") } targetRole, err := s.roomRepo.GetMemberRole(ctx, roomID, targetUserID) if err != nil || targetRole == "" { return ErrRoomNotFound } if targetRole == "owner" { return errors.New("forbidden: cannot change owner role") } if targetUserID == requestUserID { return errors.New("forbidden: owner cannot change their own role") } if newRole == "owner" { return errors.New("forbidden: cannot promote to owner via this endpoint") } return s.roomRepo.UpdateMemberRole(ctx, roomID, targetUserID, newRole) } // GetRoomMembers returns members of a room. Requesting user must be a member. func (s *RoomService) GetRoomMembers(ctx context.Context, roomID uuid.UUID, requestUserID uuid.UUID) (*RoomMembersResponse, error) { members, err := s.roomRepo.GetMembersByRoomID(ctx, roomID) if err != nil { return nil, fmt.Errorf("failed to get room members: %w", err) } var myRole string out := make([]RoomMemberResponse, 0, len(members)) for _, m := range members { username := m.User.Username out = append(out, RoomMemberResponse{UserID: m.UserID, Username: username, Role: m.Role}) if m.UserID == requestUserID { myRole = m.Role } } if myRole == "" { return nil, ErrRoomNotFound } return &RoomMembersResponse{Members: out, MyRole: myRole}, nil }