veza/veza-backend-api/internal/core/social/group_service.go

450 lines
12 KiB
Go

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
}
// 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
}