package social import ( "context" "errors" "fmt" "strings" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" "veza-backend-api/internal/models" ) var ( ErrGroupNotFound = errors.New("group not found") ErrAlreadyMember = errors.New("already a member of this group") ErrNotMember = errors.New("not a member of this group") ErrCannotLeaveOwned = errors.New("group creator cannot leave, delete the group instead") ErrRequestNotFound = errors.New("join request not found") ErrRequestAlreadyExist = errors.New("join request already pending") ErrForbidden = errors.New("forbidden: insufficient permissions") ErrUserNotFound = errors.New("user not found") ) // CreateGroup creates a new social group func (s *Service) CreateGroup(ctx context.Context, userID uuid.UUID, name, description string, isPublic bool) (*Group, error) { group := &Group{ Name: name, Description: description, CreatorID: userID, IsPublic: isPublic, MemberCount: 1, } err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(group).Error; err != nil { return fmt.Errorf("failed to create group: %w", err) } // Add creator as admin member member := &GroupMember{ GroupID: group.ID, UserID: userID, Role: "admin", } if err := tx.Create(member).Error; err != nil { return fmt.Errorf("failed to add creator as member: %w", err) } return nil }) if err != nil { return nil, err } s.logger.Info("Group created", zap.String("group_id", group.ID.String()), zap.String("creator_id", userID.String()), ) return group, nil } // ListGroups returns all public groups with pagination func (s *Service) ListGroups(ctx context.Context, limit, offset int) ([]Group, int64, error) { var groups []Group var total int64 if err := s.db.WithContext(ctx).Model(&Group{}).Where("is_public = ?", true).Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count groups: %w", err) } if err := s.db.WithContext(ctx). Where("is_public = ?", true). Order("member_count DESC, created_at DESC"). Limit(limit). Offset(offset). Find(&groups).Error; err != nil { return nil, 0, fmt.Errorf("failed to list groups: %w", err) } return groups, total, nil } // GetGroup returns a group by ID func (s *Service) GetGroup(ctx context.Context, groupID uuid.UUID) (*Group, error) { var group Group if err := s.db.WithContext(ctx).Where("id = ?", groupID).First(&group).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrGroupNotFound } return nil, fmt.Errorf("failed to get group: %w", err) } return &group, nil } // JoinGroup adds a user to a group func (s *Service) JoinGroup(ctx context.Context, userID, groupID uuid.UUID) error { // Check group exists group, err := s.GetGroup(ctx, groupID) if err != nil { return err } // Check not already a member var count int64 s.db.WithContext(ctx).Model(&GroupMember{}). Where("group_id = ? AND user_id = ?", groupID, userID). Count(&count) if count > 0 { return ErrAlreadyMember } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { member := &GroupMember{ GroupID: groupID, UserID: userID, Role: "member", } if err := tx.Create(member).Error; err != nil { return fmt.Errorf("failed to join group: %w", err) } // Increment member count if err := tx.Model(&Group{}).Where("id = ?", groupID). Update("member_count", gorm.Expr("member_count + 1")).Error; err != nil { return fmt.Errorf("failed to update member count: %w", err) } return nil }) if err != nil { return err } s.logger.Info("User joined group", zap.String("user_id", userID.String()), zap.String("group_id", groupID.String()), zap.String("group_name", group.Name), ) return nil } // LeaveGroup removes a user from a group func (s *Service) LeaveGroup(ctx context.Context, userID, groupID uuid.UUID) error { // Check group exists group, err := s.GetGroup(ctx, groupID) if err != nil { return err } // Creator cannot leave if group.CreatorID == userID { return ErrCannotLeaveOwned } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Where("group_id = ? AND user_id = ?", groupID, userID).Delete(&GroupMember{}) if result.Error != nil { return fmt.Errorf("failed to leave group: %w", result.Error) } if result.RowsAffected == 0 { return ErrNotMember } // Decrement member count if err := tx.Model(&Group{}).Where("id = ?", groupID). Update("member_count", gorm.Expr("member_count - 1")).Error; err != nil { return fmt.Errorf("failed to update member count: %w", err) } return nil }) return err } // isAdminOrModerator checks if user has admin or moderator role in group func (s *Service) isAdminOrModerator(ctx context.Context, userID, groupID uuid.UUID) (bool, error) { var member GroupMember err := s.db.WithContext(ctx). Where("group_id = ? AND user_id = ?", groupID, userID). First(&member).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return false, nil } return false, err } return member.Role == "admin" || member.Role == "moderator", nil } // RequestToJoin creates a join request for private groups, or joins directly for public groups (S2.1) func (s *Service) RequestToJoin(ctx context.Context, userID, groupID uuid.UUID) (*GroupJoinRequest, error) { group, err := s.GetGroup(ctx, groupID) if err != nil { return nil, err } var count int64 s.db.WithContext(ctx).Model(&GroupMember{}). Where("group_id = ? AND user_id = ?", groupID, userID). Count(&count) if count > 0 { return nil, ErrAlreadyMember } if group.IsPublic { if err := s.JoinGroup(ctx, userID, groupID); err != nil { return nil, err } return nil, nil } var existing GroupJoinRequest err = s.db.WithContext(ctx). Where("group_id = ? AND user_id = ? AND status = ?", groupID, userID, "pending"). First(&existing).Error if err == nil { return nil, ErrRequestAlreadyExist } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } req := &GroupJoinRequest{ GroupID: groupID, UserID: userID, Status: "pending", } if err := s.db.WithContext(ctx).Create(req).Error; err != nil { return nil, fmt.Errorf("failed to create join request: %w", err) } return req, nil } // ListJoinRequests returns pending join requests for a group (admin/modo only) func (s *Service) ListJoinRequests(ctx context.Context, groupID, userID uuid.UUID) ([]GroupJoinRequest, error) { ok, err := s.isAdminOrModerator(ctx, userID, groupID) if err != nil || !ok { return nil, ErrForbidden } var requests []GroupJoinRequest if err := s.db.WithContext(ctx). Where("group_id = ? AND status = ?", groupID, "pending"). Order("created_at DESC"). Find(&requests).Error; err != nil { return nil, fmt.Errorf("failed to list join requests: %w", err) } return requests, nil } // ApproveJoinRequest approves a join request and adds user to group func (s *Service) ApproveJoinRequest(ctx context.Context, requestID, userID uuid.UUID) error { var req GroupJoinRequest if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", requestID, "pending").First(&req).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrRequestNotFound } return err } ok, err := s.isAdminOrModerator(ctx, userID, req.GroupID) if err != nil || !ok { return ErrForbidden } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Model(&GroupJoinRequest{}).Where("id = ?", requestID).Update("status", "approved").Error; err != nil { return err } member := &GroupMember{ GroupID: req.GroupID, UserID: req.UserID, Role: "member", } if err := tx.Create(member).Error; err != nil { return fmt.Errorf("failed to add member: %w", err) } if err := tx.Model(&Group{}).Where("id = ?", req.GroupID). Update("member_count", gorm.Expr("member_count + 1")).Error; err != nil { return err } return nil }) } // RejectJoinRequest rejects a join request func (s *Service) RejectJoinRequest(ctx context.Context, requestID, userID uuid.UUID) error { var req GroupJoinRequest if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", requestID, "pending").First(&req).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrRequestNotFound } return err } ok, err := s.isAdminOrModerator(ctx, userID, req.GroupID) if err != nil || !ok { return ErrForbidden } return s.db.WithContext(ctx).Model(&GroupJoinRequest{}).Where("id = ?", requestID).Update("status", "rejected").Error } // InviteMember invites a user to join a group (admin/modo only) (S2.2) func (s *Service) InviteMember(ctx context.Context, inviterID, groupID, inviteeID uuid.UUID) error { ok, err := s.isAdminOrModerator(ctx, inviterID, groupID) if err != nil || !ok { return ErrForbidden } group, err := s.GetGroup(ctx, groupID) if err != nil { return err } var user models.User if err := s.db.WithContext(ctx).Where("id = ?", inviteeID).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrUserNotFound } return err } var count int64 s.db.WithContext(ctx).Model(&GroupMember{}). Where("group_id = ? AND user_id = ?", groupID, inviteeID). Count(&count) if count > 0 { return ErrAlreadyMember } var existing GroupInvitation err = s.db.WithContext(ctx). Where("group_id = ? AND invitee_id = ? AND status = ?", groupID, inviteeID, "pending"). First(&existing).Error if err == nil { return fmt.Errorf("invitation already pending") } if !errors.Is(err, gorm.ErrRecordNotFound) { return err } inv := &GroupInvitation{ GroupID: groupID, InviterID: inviterID, InviteeID: inviteeID, Status: "pending", } if err := s.db.WithContext(ctx).Create(inv).Error; err != nil { return fmt.Errorf("failed to create invitation: %w", err) } s.logger.Info("Group invitation created", zap.String("group_id", groupID.String()), zap.String("inviter_id", inviterID.String()), zap.String("invitee_id", inviteeID.String()), zap.String("group_name", group.Name), ) return nil } // ResolveInviteeByEmailOrID returns user ID from email or UUID string func (s *Service) ResolveInviteeByEmailOrID(ctx context.Context, emailOrID string) (uuid.UUID, error) { emailOrID = strings.TrimSpace(emailOrID) if emailOrID == "" { return uuid.Nil, ErrUserNotFound } if id, err := uuid.Parse(emailOrID); err == nil { var user models.User if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return uuid.Nil, ErrUserNotFound } return uuid.Nil, err } return id, nil } var user models.User if err := s.db.WithContext(ctx).Where("email = ?", emailOrID).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return uuid.Nil, ErrUserNotFound } return uuid.Nil, err } return user.ID, nil } // UpdateMemberRole updates a member's role (admin only) (S2.3) func (s *Service) UpdateMemberRole(ctx context.Context, adminID, groupID, targetUserID uuid.UUID, newRole string) error { if newRole != "admin" && newRole != "moderator" && newRole != "member" { return fmt.Errorf("invalid role: %s", newRole) } ok, err := s.isAdminOrModerator(ctx, adminID, groupID) if err != nil || !ok { return ErrForbidden } var adminMember GroupMember if err := s.db.WithContext(ctx).Where("group_id = ? AND user_id = ?", groupID, adminID).First(&adminMember).Error; err != nil { return ErrForbidden } if adminMember.Role != "admin" { return ErrForbidden } result := s.db.WithContext(ctx).Model(&GroupMember{}). Where("group_id = ? AND user_id = ?", groupID, targetUserID). Update("role", newRole) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return ErrNotMember } return nil } // GetGroupMemberStatus returns the current user's status in a group (for frontend) func (s *Service) GetGroupMemberStatus(ctx context.Context, userID, groupID uuid.UUID) (isMember bool, role string, hasPendingRequest bool, err error) { var member GroupMember err = s.db.WithContext(ctx). Where("group_id = ? AND user_id = ?", groupID, userID). First(&member).Error if err == nil { return true, member.Role, false, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return false, "", false, err } var req GroupJoinRequest err = s.db.WithContext(ctx). Where("group_id = ? AND user_id = ? AND status = ?", groupID, userID, "pending"). First(&req).Error if err == nil { return false, "", true, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return false, "", false, err } return false, "", false, nil } // ListMyGroups returns groups the user is a member of (S2.5) func (s *Service) ListMyGroups(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Group, int64, error) { subQuery := s.db.WithContext(ctx).Model(&GroupMember{}). Select("group_id"). Where("user_id = ?", userID) var total int64 if err := s.db.WithContext(ctx).Model(&Group{}). Where("id IN (?)", subQuery). Count(&total).Error; err != nil { return nil, 0, err } var groups []Group if err := s.db.WithContext(ctx). Where("id IN (?)", subQuery). Order("member_count DESC, created_at DESC"). Limit(limit). Offset(offset). Find(&groups).Error; err != nil { return nil, 0, err } return groups, total, nil }