450 lines
12 KiB
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
|
|
}
|