feat(groups): S2.1-S2.5 request join, invite, roles, feed groups, my groups
This commit is contained in:
parent
6cd69f1e62
commit
7ca8d14283
7 changed files with 621 additions and 5 deletions
|
|
@ -27,6 +27,9 @@ func (r *APIRouter) setupSocialRoutes(router *gin.RouterGroup) {
|
|||
social.GET("/trending", socialHandler.GetTrending)
|
||||
social.GET("/posts/user/:user_id", socialHandler.GetPostsByUser)
|
||||
social.GET("/groups", groupHandler.ListGroups)
|
||||
if r.config.AuthMiddleware != nil {
|
||||
social.GET("/groups/mine", r.config.AuthMiddleware.RequireAuth(), groupHandler.ListMyGroups)
|
||||
}
|
||||
social.GET("/groups/:id", groupHandler.GetGroup)
|
||||
|
||||
if r.config.AuthMiddleware != nil {
|
||||
|
|
@ -41,6 +44,12 @@ func (r *APIRouter) setupSocialRoutes(router *gin.RouterGroup) {
|
|||
protected.POST("/groups", groupHandler.CreateGroup)
|
||||
protected.POST("/groups/:id/join", groupHandler.JoinGroup)
|
||||
protected.DELETE("/groups/:id/leave", groupHandler.LeaveGroup)
|
||||
protected.POST("/groups/:id/request", groupHandler.RequestToJoin)
|
||||
protected.GET("/groups/:id/requests", groupHandler.ListJoinRequests)
|
||||
protected.POST("/groups/:id/requests/:request_id/approve", groupHandler.ApproveJoinRequest)
|
||||
protected.POST("/groups/:id/requests/:request_id/reject", groupHandler.RejectJoinRequest)
|
||||
protected.POST("/groups/:id/invite", groupHandler.InviteMember)
|
||||
protected.PUT("/groups/:id/members/:user_id/role", groupHandler.UpdateMemberRole)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,3 +43,46 @@ func (gm *GroupMember) BeforeCreate(tx *gorm.DB) (err error) {
|
|||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GroupJoinRequest represents a user's request to join a private group (v0.302 S2.1)
|
||||
type GroupJoinRequest struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
GroupID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_group_join_request" json:"group_id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_group_join_request" json:"user_id"`
|
||||
Status string `gorm:"default:'pending';size:20" json:"status"` // pending, approved, rejected
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (gjr *GroupJoinRequest) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if gjr.ID == uuid.Nil {
|
||||
gjr.ID = uuid.New()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TableName overrides the table name for GroupJoinRequest
|
||||
func (GroupJoinRequest) TableName() string {
|
||||
return "group_join_requests"
|
||||
}
|
||||
|
||||
// GroupInvitation represents an invitation to join a group (v0.302 S2.2)
|
||||
type GroupInvitation struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
GroupID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_group_invitation" json:"group_id"`
|
||||
InviterID uuid.UUID `gorm:"type:uuid;not null" json:"inviter_id"`
|
||||
InviteeID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_group_invitation" json:"invitee_id"`
|
||||
Status string `gorm:"default:'pending';size:20" json:"status"` // pending, accepted, declined
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (gi *GroupInvitation) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if gi.ID == uuid.Nil {
|
||||
gi.ID = uuid.New()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TableName overrides the table name for GroupInvitation
|
||||
func (GroupInvitation) TableName() string {
|
||||
return "group_invitations"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,24 @@ 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")
|
||||
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
|
||||
|
|
@ -172,3 +179,272 @@ func (s *Service) LeaveGroup(ctx context.Context, userID, groupID uuid.UUID) err
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,14 @@ func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int, feedType
|
|||
if feedType == "following" && userID != nil {
|
||||
// Posts from users that current user follows
|
||||
query = query.Joins("INNER JOIN follows ON follows.followed_id = posts.user_id AND follows.follower_id = ?", *userID)
|
||||
} else if feedType == "groups" && userID != nil {
|
||||
// S2.4: Posts from members of groups the current user belongs to
|
||||
// Subquery: user_ids that share at least one group with current user
|
||||
subQuery := s.db.WithContext(ctx).Model(&GroupMember{}).
|
||||
Select("DISTINCT gm2.user_id").
|
||||
Joins("INNER JOIN group_members gm2 ON group_members.group_id = gm2.group_id AND gm2.user_id != ?", *userID).
|
||||
Where("group_members.user_id = ?", *userID)
|
||||
query = query.Where("posts.user_id IN (?)", subQuery)
|
||||
}
|
||||
var posts []Post
|
||||
if err := query.Order("posts.created_at desc").Limit(limit).Offset(offset).Find(&posts).Error; err != nil {
|
||||
|
|
|
|||
|
|
@ -172,3 +172,269 @@ func (h *GroupHandler) LeaveGroup(c *gin.Context) {
|
|||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "Left group successfully"})
|
||||
}
|
||||
|
||||
// RequestToJoin creates a join request for private groups (S2.1)
|
||||
func (h *GroupHandler) RequestToJoin(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
groupID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group ID"})
|
||||
return
|
||||
}
|
||||
|
||||
req, err := h.service.RequestToJoin(c.Request.Context(), userID, groupID)
|
||||
if err != nil {
|
||||
if errors.Is(err, social.ErrGroupNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Group not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, social.ErrAlreadyMember) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Already a member"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, social.ErrRequestAlreadyExist) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Join request already pending"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to request join"})
|
||||
return
|
||||
}
|
||||
|
||||
if req == nil {
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "Joined group successfully"})
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, http.StatusCreated, req)
|
||||
}
|
||||
|
||||
// ListJoinRequests returns pending join requests (admin/modo only) (S2.1)
|
||||
func (h *GroupHandler) ListJoinRequests(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
groupID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group ID"})
|
||||
return
|
||||
}
|
||||
|
||||
requests, err := h.service.ListJoinRequests(c.Request.Context(), groupID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, social.ErrForbidden) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list requests"})
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"requests": requests})
|
||||
}
|
||||
|
||||
// ApproveJoinRequest approves a join request (S2.1)
|
||||
func (h *GroupHandler) ApproveJoinRequest(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
requestID, err := uuid.Parse(c.Param("request_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.ApproveJoinRequest(c.Request.Context(), requestID, userID); err != nil {
|
||||
if errors.Is(err, social.ErrRequestNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, social.ErrForbidden) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve request"})
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "Request approved"})
|
||||
}
|
||||
|
||||
// RejectJoinRequest rejects a join request (S2.1)
|
||||
func (h *GroupHandler) RejectJoinRequest(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
requestID, err := uuid.Parse(c.Param("request_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.RejectJoinRequest(c.Request.Context(), requestID, userID); err != nil {
|
||||
if errors.Is(err, social.ErrRequestNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, social.ErrForbidden) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject request"})
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "Request rejected"})
|
||||
}
|
||||
|
||||
// InviteMemberRequest is the DTO for inviting a member
|
||||
type InviteMemberRequest struct {
|
||||
Email *string `json:"email"`
|
||||
UserID *uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
// InviteMember invites a user to join a group (S2.2)
|
||||
func (h *GroupHandler) InviteMember(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
groupID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req InviteMemberRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
var inviteeID uuid.UUID
|
||||
if req.UserID != nil {
|
||||
inviteeID = *req.UserID
|
||||
} else if req.Email != nil {
|
||||
inviteeID, err = h.service.ResolveInviteeByEmailOrID(c.Request.Context(), *req.Email)
|
||||
if err != nil {
|
||||
if errors.Is(err, social.ErrUserNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve user"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email or user_id required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.InviteMember(c.Request.Context(), userID, groupID, inviteeID); err != nil {
|
||||
if errors.Is(err, social.ErrForbidden) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, social.ErrAlreadyMember) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User is already a member"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, social.ErrUserNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
if err.Error() == "invitation already pending" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Invitation already pending"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to invite member"})
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusCreated, gin.H{"message": "Invitation sent"})
|
||||
}
|
||||
|
||||
// UpdateMemberRoleRequest is the DTO for updating member role
|
||||
type UpdateMemberRoleRequest struct {
|
||||
Role string `json:"role" binding:"required,oneof=admin moderator member"`
|
||||
}
|
||||
|
||||
// UpdateMemberRole updates a member's role (S2.3)
|
||||
func (h *GroupHandler) UpdateMemberRole(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
groupID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group ID"})
|
||||
return
|
||||
}
|
||||
|
||||
targetUserID, err := uuid.Parse(c.Param("user_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateMemberRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.UpdateMemberRole(c.Request.Context(), userID, groupID, targetUserID, req.Role); err != nil {
|
||||
if errors.Is(err, social.ErrForbidden) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, social.ErrNotMember) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User is not a member"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update role"})
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "Role updated"})
|
||||
}
|
||||
|
||||
// ListMyGroups returns groups the user is a member of (S2.5)
|
||||
func (h *GroupHandler) ListMyGroups(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
groups, total, err := h.service.ListMyGroups(c.Request.Context(), userID, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list groups"})
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"groups": groups,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ CREATE TABLE IF NOT EXISTS group_join_requests (
|
|||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(group_id, user_id)
|
||||
);
|
||||
|
||||
|
|
|
|||
15
veza-backend-api/migrations/092_group_invitations.sql
Normal file
15
veza-backend-api/migrations/092_group_invitations.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- Migration 092: Group invitations (v0.302 Lot S2.2)
|
||||
-- Admin/moderator can invite users to join a group
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_invitations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
inviter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
invitee_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(group_id, invitee_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_invitations_group_id ON group_invitations(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_invitations_invitee_id ON group_invitations(invitee_id);
|
||||
Loading…
Reference in a new issue