feat(groups): S2.1-S2.5 request join, invite, roles, feed groups, my groups

This commit is contained in:
senke 2026-02-21 05:48:59 +01:00
parent 6cd69f1e62
commit 7ca8d14283
7 changed files with 621 additions and 5 deletions

View file

@ -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)
}
}
}

View file

@ -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"
}

View file

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

View file

@ -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 {

View file

@ -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,
})
}

View file

@ -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)
);

View 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);