diff --git a/veza-backend-api/internal/api/routes_social.go b/veza-backend-api/internal/api/routes_social.go index b95a7b902..4d95ca332 100644 --- a/veza-backend-api/internal/api/routes_social.go +++ b/veza-backend-api/internal/api/routes_social.go @@ -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) } } } diff --git a/veza-backend-api/internal/core/social/group_models.go b/veza-backend-api/internal/core/social/group_models.go index 185603440..6006caae9 100644 --- a/veza-backend-api/internal/core/social/group_models.go +++ b/veza-backend-api/internal/core/social/group_models.go @@ -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" +} diff --git a/veza-backend-api/internal/core/social/group_service.go b/veza-backend-api/internal/core/social/group_service.go index 372a9247f..17daa6c0d 100644 --- a/veza-backend-api/internal/core/social/group_service.go +++ b/veza-backend-api/internal/core/social/group_service.go @@ -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 +} diff --git a/veza-backend-api/internal/core/social/service.go b/veza-backend-api/internal/core/social/service.go index 04da6eb82..dbdc6f1d6 100644 --- a/veza-backend-api/internal/core/social/service.go +++ b/veza-backend-api/internal/core/social/service.go @@ -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 { diff --git a/veza-backend-api/internal/handlers/social_group_handler.go b/veza-backend-api/internal/handlers/social_group_handler.go index 0e18126cc..b6d20b093 100644 --- a/veza-backend-api/internal/handlers/social_group_handler.go +++ b/veza-backend-api/internal/handlers/social_group_handler.go @@ -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, + }) +} diff --git a/veza-backend-api/migrations/089_group_join_requests.sql b/veza-backend-api/migrations/089_group_join_requests.sql index 9ba8f13c7..c2cc017f6 100644 --- a/veza-backend-api/migrations/089_group_join_requests.sql +++ b/veza-backend-api/migrations/089_group_join_requests.sql @@ -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) ); diff --git a/veza-backend-api/migrations/092_group_invitations.sql b/veza-backend-api/migrations/092_group_invitations.sql new file mode 100644 index 000000000..3578c0070 --- /dev/null +++ b/veza-backend-api/migrations/092_group_invitations.sql @@ -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);