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("/trending", socialHandler.GetTrending)
social.GET("/posts/user/:user_id", socialHandler.GetPostsByUser) social.GET("/posts/user/:user_id", socialHandler.GetPostsByUser)
social.GET("/groups", groupHandler.ListGroups) 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) social.GET("/groups/:id", groupHandler.GetGroup)
if r.config.AuthMiddleware != nil { if r.config.AuthMiddleware != nil {
@ -41,6 +44,12 @@ func (r *APIRouter) setupSocialRoutes(router *gin.RouterGroup) {
protected.POST("/groups", groupHandler.CreateGroup) protected.POST("/groups", groupHandler.CreateGroup)
protected.POST("/groups/:id/join", groupHandler.JoinGroup) protected.POST("/groups/:id/join", groupHandler.JoinGroup)
protected.DELETE("/groups/:id/leave", groupHandler.LeaveGroup) 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 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" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
"veza-backend-api/internal/models"
) )
var ( var (
ErrGroupNotFound = errors.New("group not found") ErrGroupNotFound = errors.New("group not found")
ErrAlreadyMember = errors.New("already a member of this group") ErrAlreadyMember = errors.New("already a member of this group")
ErrNotMember = errors.New("not 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") 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 // CreateGroup creates a new social group
@ -172,3 +179,272 @@ func (s *Service) LeaveGroup(ctx context.Context, userID, groupID uuid.UUID) err
return 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 { if feedType == "following" && userID != nil {
// Posts from users that current user follows // 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) 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 var posts []Post
if err := query.Order("posts.created_at desc").Limit(limit).Offset(offset).Find(&posts).Error; err != nil { 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"}) 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, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(group_id, user_id) 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);