package handlers import ( "database/sql" "fmt" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" ) type GroupsHandler struct { db *pgxpool.Pool } func NewGroupsHandler(db *pgxpool.Pool) *GroupsHandler { return &GroupsHandler{db: db} } type Group struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Category string `json:"category"` AvatarURL *string `json:"avatar_url"` BannerURL *string `json:"banner_url"` IsPrivate bool `json:"is_private"` CreatedBy string `json:"created_by"` MemberCount int `json:"member_count"` PostCount int `json:"post_count"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` UserRole *string `json:"user_role,omitempty"` IsMember bool `json:"is_member"` HasPending bool `json:"has_pending_request,omitempty"` } type GroupMember struct { ID string `json:"id"` GroupID string `json:"group_id"` UserID string `json:"user_id"` Role string `json:"role"` JoinedAt time.Time `json:"joined_at"` Username string `json:"username,omitempty"` Avatar *string `json:"avatar_url,omitempty"` } type JoinRequest struct { ID string `json:"id"` GroupID string `json:"group_id"` UserID string `json:"user_id"` Status string `json:"status"` Message *string `json:"message"` CreatedAt time.Time `json:"created_at"` ReviewedAt *time.Time `json:"reviewed_at"` ReviewedBy *string `json:"reviewed_by"` Username string `json:"username,omitempty"` Avatar *string `json:"avatar_url,omitempty"` } // ListGroups returns all groups with optional category filter func (h *GroupsHandler) ListGroups(c *gin.Context) { userID := c.GetString("user_id") category := c.Query("category") page := c.DefaultQuery("page", "0") limit := c.DefaultQuery("limit", "20") query := ` SELECT g.id, g.name, g.description, g.category, g.avatar_url, g.banner_url, g.is_private, g.created_by, g.member_count, g.post_count, g.created_at, g.updated_at, gm.role, EXISTS(SELECT 1 FROM group_members WHERE group_id = g.id AND user_id = $1) as is_member, EXISTS(SELECT 1 FROM group_join_requests WHERE group_id = g.id AND user_id = $1 AND status = 'pending') as has_pending FROM groups g LEFT JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = $1 WHERE ($2 = '' OR g.category = $2) ORDER BY g.member_count DESC, g.created_at DESC LIMIT $3 OFFSET $4 ` rows, err := h.db.Query(c.Request.Context(), query, userID, category, limit, page) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch groups"}) return } defer rows.Close() groups := []Group{} for rows.Next() { var g Group err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Category, &g.AvatarURL, &g.BannerURL, &g.IsPrivate, &g.CreatedBy, &g.MemberCount, &g.PostCount, &g.CreatedAt, &g.UpdatedAt, &g.UserRole, &g.IsMember, &g.HasPending) if err != nil { continue } groups = append(groups, g) } c.JSON(http.StatusOK, gin.H{"groups": groups}) } // GetMyGroups returns groups the user is a member of func (h *GroupsHandler) GetMyGroups(c *gin.Context) { userID := c.GetString("user_id") query := ` SELECT g.id, g.name, g.description, g.category, g.avatar_url, g.banner_url, g.is_private, g.created_by, g.member_count, g.post_count, g.created_at, g.updated_at, gm.role FROM groups g JOIN group_members gm ON g.id = gm.group_id WHERE gm.user_id = $1 ORDER BY gm.joined_at DESC ` rows, err := h.db.Query(c.Request.Context(), query, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch groups"}) return } defer rows.Close() groups := []Group{} for rows.Next() { var g Group g.IsMember = true err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Category, &g.AvatarURL, &g.BannerURL, &g.IsPrivate, &g.CreatedBy, &g.MemberCount, &g.PostCount, &g.CreatedAt, &g.UpdatedAt, &g.UserRole) if err != nil { continue } groups = append(groups, g) } c.JSON(http.StatusOK, gin.H{"groups": groups}) } // GetSuggestedGroups returns suggested groups for the user func (h *GroupsHandler) GetSuggestedGroups(c *gin.Context) { userID := c.GetString("user_id") limit := c.DefaultQuery("limit", "10") query := `SELECT * FROM get_suggested_groups($1, $2)` rows, err := h.db.Query(c.Request.Context(), query, userID, limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch suggestions"}) return } defer rows.Close() type SuggestedGroup struct { Group Reason string `json:"reason"` } groups := []SuggestedGroup{} for rows.Next() { var sg SuggestedGroup err := rows.Scan(&sg.ID, &sg.Name, &sg.Description, &sg.Category, &sg.AvatarURL, &sg.IsPrivate, &sg.MemberCount, &sg.PostCount, &sg.Reason) if err != nil { continue } sg.IsMember = false groups = append(groups, sg) } c.JSON(http.StatusOK, gin.H{"suggestions": groups}) } // GetGroup returns a single group by ID func (h *GroupsHandler) GetGroup(c *gin.Context) { userID := c.GetString("user_id") groupID := c.Param("id") query := ` SELECT g.id, g.name, g.description, g.category, g.avatar_url, g.banner_url, g.is_private, g.created_by, g.member_count, g.post_count, g.created_at, g.updated_at, gm.role, EXISTS(SELECT 1 FROM group_members WHERE group_id = g.id AND user_id = $2) as is_member, EXISTS(SELECT 1 FROM group_join_requests WHERE group_id = g.id AND user_id = $2 AND status = 'pending') as has_pending FROM groups g LEFT JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = $2 WHERE g.id = $1 ` var g Group err := h.db.QueryRow(c.Request.Context(), query, groupID, userID).Scan( &g.ID, &g.Name, &g.Description, &g.Category, &g.AvatarURL, &g.BannerURL, &g.IsPrivate, &g.CreatedBy, &g.MemberCount, &g.PostCount, &g.CreatedAt, &g.UpdatedAt, &g.UserRole, &g.IsMember, &g.HasPending) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Group not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch group"}) return } c.JSON(http.StatusOK, gin.H{"group": g}) } // CreateGroup creates a new group func (h *GroupsHandler) CreateGroup(c *gin.Context) { userID := c.GetString("user_id") var req struct { Name string `json:"name" binding:"required,max=50"` Description string `json:"description" binding:"max=300"` Category string `json:"category" binding:"required"` IsPrivate bool `json:"is_private"` AvatarURL *string `json:"avatar_url"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Normalize name for uniqueness check req.Name = strings.TrimSpace(req.Name) privacy := "public" if req.IsPrivate { privacy = "private" } tx, err := h.db.Begin(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"}) return } defer tx.Rollback(c.Request.Context()) // Create group var groupID string err = tx.QueryRow(c.Request.Context(), ` INSERT INTO groups (name, description, category, privacy, created_by, avatar_url) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id `, req.Name, req.Description, req.Category, privacy, userID, req.AvatarURL).Scan(&groupID) if err != nil { if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") { c.JSON(http.StatusConflict, gin.H{"error": "A group with this name already exists"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"}) return } // Add creator as owner _, err = tx.Exec(c.Request.Context(), ` INSERT INTO group_members (group_id, user_id, role) VALUES ($1, $2, 'owner') `, groupID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add owner"}) return } if err = tx.Commit(c.Request.Context()); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"}) return } c.JSON(http.StatusCreated, gin.H{"group_id": groupID, "message": "Group created successfully"}) } // JoinGroup allows a user to join a public group or request to join a private group func (h *GroupsHandler) JoinGroup(c *gin.Context) { userID := c.GetString("user_id") groupID := c.Param("id") var req struct { Message *string `json:"message"` } c.ShouldBindJSON(&req) // Check if group exists and is private var isPrivate bool err := h.db.QueryRow(c.Request.Context(), `SELECT is_private FROM groups WHERE id = $1`, groupID).Scan(&isPrivate) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Group not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to join group"}) return } // Check if already a member var exists bool err = h.db.QueryRow(c.Request.Context(), ` SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2) `, groupID, userID).Scan(&exists) if err == nil && exists { c.JSON(http.StatusConflict, gin.H{"error": "Already a member"}) return } if isPrivate { // Create join request _, err = h.db.Exec(c.Request.Context(), ` INSERT INTO group_join_requests (group_id, user_id, message) VALUES ($1, $2, $3) ON CONFLICT (group_id, user_id, status) DO NOTHING `, groupID, userID, req.Message) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create join request"}) return } c.JSON(http.StatusOK, gin.H{"message": "Join request sent", "status": "pending"}) } else { // Join immediately _, err = h.db.Exec(c.Request.Context(), ` INSERT INTO group_members (group_id, user_id, role) VALUES ($1, $2, 'member') `, groupID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to join group"}) return } c.JSON(http.StatusOK, gin.H{"message": "Joined successfully", "status": "joined"}) } } // LeaveGroup allows a user to leave a group func (h *GroupsHandler) LeaveGroup(c *gin.Context) { userID := c.GetString("user_id") groupID := c.Param("id") // Check if user is owner var role string err := h.db.QueryRow(c.Request.Context(), ` SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 `, groupID, userID).Scan(&role) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Not a member of this group"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to leave group"}) return } if role == "owner" { c.JSON(http.StatusForbidden, gin.H{"error": "Owner must transfer ownership or delete group before leaving"}) return } // Remove member _, err = h.db.Exec(c.Request.Context(), ` DELETE FROM group_members WHERE group_id = $1 AND user_id = $2 `, groupID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to leave group"}) return } // Flag key rotation so admin client silently rotates on next open h.db.Exec(c.Request.Context(), `UPDATE groups SET key_rotation_needed = true WHERE id = $1`, groupID) c.JSON(http.StatusOK, gin.H{"message": "Left group successfully"}) } // GetGroupMembers returns members of a group func (h *GroupsHandler) GetGroupMembers(c *gin.Context) { groupID := c.Param("id") page := c.DefaultQuery("page", "0") limit := c.DefaultQuery("limit", "50") query := ` SELECT gm.id, gm.group_id, gm.user_id, gm.role, gm.joined_at, p.username, p.avatar_url FROM group_members gm JOIN profiles p ON gm.user_id = p.user_id WHERE gm.group_id = $1 ORDER BY CASE gm.role WHEN 'owner' THEN 1 WHEN 'admin' THEN 2 WHEN 'moderator' THEN 3 ELSE 4 END, gm.joined_at ASC LIMIT $2 OFFSET $3 ` rows, err := h.db.Query(c.Request.Context(), query, groupID, limit, page) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch members"}) return } defer rows.Close() members := []GroupMember{} for rows.Next() { var m GroupMember err := rows.Scan(&m.ID, &m.GroupID, &m.UserID, &m.Role, &m.JoinedAt, &m.Username, &m.Avatar) if err != nil { continue } members = append(members, m) } c.JSON(http.StatusOK, gin.H{"members": members}) } // GetPendingRequests returns pending join requests for a group (admin only) func (h *GroupsHandler) GetPendingRequests(c *gin.Context) { userID := c.GetString("user_id") groupID := c.Param("id") // Check if user is admin/owner var role string err := h.db.QueryRow(c.Request.Context(), ` SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 `, groupID, userID).Scan(&role) if err != nil || (role != "owner" && role != "admin") { c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) return } query := ` SELECT jr.id, jr.group_id, jr.user_id, jr.status, jr.message, jr.created_at, jr.reviewed_at, jr.reviewed_by, p.username, p.avatar_url FROM group_join_requests jr JOIN profiles p ON jr.user_id = p.user_id WHERE jr.group_id = $1 AND jr.status = 'pending' ORDER BY jr.created_at ASC ` rows, err := h.db.Query(c.Request.Context(), query, groupID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"}) return } defer rows.Close() requests := []JoinRequest{} for rows.Next() { var jr JoinRequest err := rows.Scan(&jr.ID, &jr.GroupID, &jr.UserID, &jr.Status, &jr.Message, &jr.CreatedAt, &jr.ReviewedAt, &jr.ReviewedBy, &jr.Username, &jr.Avatar) if err != nil { continue } requests = append(requests, jr) } c.JSON(http.StatusOK, gin.H{"requests": requests}) } // ApproveJoinRequest approves a join request (admin only) func (h *GroupsHandler) ApproveJoinRequest(c *gin.Context) { userID := c.GetString("user_id") groupID := c.Param("id") requestID := c.Param("requestId") // Check if user is admin/owner var role string err := h.db.QueryRow(c.Request.Context(), ` SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 `, groupID, userID).Scan(&role) if err != nil || (role != "owner" && role != "admin") { c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) return } tx, err := h.db.Begin(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve request"}) return } defer tx.Rollback(c.Request.Context()) // Get requester user ID var requesterID string err = tx.QueryRow(c.Request.Context(), ` SELECT user_id FROM group_join_requests WHERE id = $1 AND group_id = $2 AND status = 'pending' `, requestID, groupID).Scan(&requesterID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve request"}) return } // Update request status _, err = tx.Exec(c.Request.Context(), ` UPDATE group_join_requests SET status = 'approved', reviewed_at = NOW(), reviewed_by = $1 WHERE id = $2 `, userID, requestID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve request"}) return } // Add user as member _, err = tx.Exec(c.Request.Context(), ` INSERT INTO group_members (group_id, user_id, role) VALUES ($1, $2, 'member') `, groupID, requesterID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add member"}) return } if err = tx.Commit(c.Request.Context()); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve request"}) return } c.JSON(http.StatusOK, gin.H{"message": "Request approved"}) } // RejectJoinRequest rejects a join request (admin only) func (h *GroupsHandler) RejectJoinRequest(c *gin.Context) { userID := c.GetString("user_id") groupID := c.Param("id") requestID := c.Param("requestId") // Check if user is admin/owner var role string err := h.db.QueryRow(c.Request.Context(), ` SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2 `, groupID, userID).Scan(&role) if err != nil || (role != "owner" && role != "admin") { c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) return } _, err = h.db.Exec(c.Request.Context(), ` UPDATE group_join_requests SET status = 'rejected', reviewed_at = NOW(), reviewed_by = $1 WHERE id = $2 AND group_id = $3 AND status = 'pending' `, userID, requestID, groupID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject request"}) return } c.JSON(http.StatusOK, gin.H{"message": "Request rejected"}) } // ────────────────────────────────────────────────────────────────────────────── // Group feed // ────────────────────────────────────────────────────────────────────────────── // GetGroupFeed GET /groups/:id/feed?limit=20&offset=0 func (h *GroupsHandler) GetGroupFeed(c *gin.Context) { groupID := c.Param("id") limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) if limit <= 0 || limit > 100 { limit = 20 } rows, err := h.db.Query(c.Request.Context(), ` SELECT p.id, p.user_id, p.content, p.image_url, p.video_url, p.thumbnail_url, p.created_at, p.status FROM posts p JOIN group_posts gp ON gp.post_id = p.id WHERE gp.group_id = $1 AND p.status = 'active' ORDER BY p.created_at DESC LIMIT $2 OFFSET $3 `, groupID, limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch group feed"}) return } defer rows.Close() type feedPost struct { ID string `json:"id"` UserID string `json:"user_id"` Content string `json:"content"` ImageURL *string `json:"image_url"` VideoURL *string `json:"video_url"` ThumbnailURL *string `json:"thumbnail_url"` CreatedAt time.Time `json:"created_at"` Status string `json:"status"` } var posts []feedPost for rows.Next() { var p feedPost if err := rows.Scan(&p.ID, &p.UserID, &p.Content, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.CreatedAt, &p.Status); err != nil { continue } posts = append(posts, p) } c.JSON(http.StatusOK, gin.H{"posts": posts, "limit": limit, "offset": offset}) } // ────────────────────────────────────────────────────────────────────────────── // E2EE group key management // ────────────────────────────────────────────────────────────────────────────── // GetGroupKeyStatus GET /groups/:id/key-status // Returns the current key version, whether rotation is needed, and the caller's // encrypted group key (if they have one). func (h *GroupsHandler) GetGroupKeyStatus(c *gin.Context) { groupID := c.Param("id") userID, _ := c.Get("user_id") var keyVersion int var keyRotationNeeded bool err := h.db.QueryRow(c.Request.Context(), `SELECT key_version, key_rotation_needed FROM groups WHERE id = $1`, groupID, ).Scan(&keyVersion, &keyRotationNeeded) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) return } // Fetch this user's encrypted key for the current version var encryptedKey *string h.db.QueryRow(c.Request.Context(), `SELECT encrypted_key FROM group_member_keys WHERE group_id = $1 AND user_id = $2 AND key_version = $3`, groupID, userID, keyVersion, ).Scan(&encryptedKey) c.JSON(http.StatusOK, gin.H{ "key_version": keyVersion, "key_rotation_needed": keyRotationNeeded, "my_encrypted_key": encryptedKey, }) } // DistributeGroupKeys POST /groups/:id/keys // Called by an admin/owner client after local key rotation to push new // encrypted copies to each member. // Body: {"keys": [{"user_id": "...", "encrypted_key": "...", "key_version": N}]} func (h *GroupsHandler) DistributeGroupKeys(c *gin.Context) { groupID := c.Param("id") callerID, _ := c.Get("user_id") // Only owner/admin may distribute keys var role string err := h.db.QueryRow(c.Request.Context(), `SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, callerID, ).Scan(&role) if err != nil || (role != "owner" && role != "admin") { c.JSON(http.StatusForbidden, gin.H{"error": "only group owners or admins may rotate keys"}) return } var req struct { Keys []struct { UserID string `json:"user_id" binding:"required"` EncryptedKey string `json:"encrypted_key" binding:"required"` KeyVersion int `json:"key_version" binding:"required"` } `json:"keys" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Determine the new key version (max of submitted versions) newVersion := 0 for _, k := range req.Keys { if k.KeyVersion > newVersion { newVersion = k.KeyVersion } } for _, k := range req.Keys { h.db.Exec(c.Request.Context(), ` INSERT INTO group_member_keys (group_id, user_id, key_version, encrypted_key, updated_at) VALUES ($1, $2, $3, $4, now()) ON CONFLICT (group_id, user_id, key_version) DO UPDATE SET encrypted_key = EXCLUDED.encrypted_key, updated_at = now() `, groupID, k.UserID, k.KeyVersion, k.EncryptedKey) } // Clear the rotation flag and bump key_version on the group h.db.Exec(c.Request.Context(), `UPDATE groups SET key_rotation_needed = false, key_version = $1 WHERE id = $2`, newVersion, groupID) c.JSON(http.StatusOK, gin.H{"message": "keys distributed", "key_version": newVersion}) } // GetGroupMemberPublicKeys GET /groups/:id/members/public-keys // Returns RSA public keys for all members so a rotating client can encrypt for each. func (h *GroupsHandler) GetGroupMemberPublicKeys(c *gin.Context) { groupID := c.Param("id") callerID, _ := c.Get("user_id") // Caller must be a member var memberCount int err := h.db.QueryRow(c.Request.Context(), `SELECT COUNT(*) FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, callerID, ).Scan(&memberCount) if err != nil || memberCount == 0 { c.JSON(http.StatusForbidden, gin.H{"error": "not a group member"}) return } rows, err := h.db.Query(c.Request.Context(), ` SELECT gm.user_id, u.public_key FROM group_members gm JOIN users u ON u.id = gm.user_id WHERE gm.group_id = $1 AND u.public_key IS NOT NULL AND u.public_key != '' `, groupID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch member keys"}) return } defer rows.Close() type memberKey struct { UserID string `json:"user_id"` PublicKey string `json:"public_key"` } var keys []memberKey for rows.Next() { var mk memberKey if rows.Scan(&mk.UserID, &mk.PublicKey) == nil { keys = append(keys, mk) } } c.JSON(http.StatusOK, gin.H{"keys": keys}) } // ────────────────────────────────────────────────────────────────────────────── // Member invite / remove / settings // ────────────────────────────────────────────────────────────────────────────── // InviteMember POST /groups/:id/invite-member // Body: {"user_id": "...", "encrypted_key": "..."} func (h *GroupsHandler) InviteMember(c *gin.Context) { groupID := c.Param("id") callerID, _ := c.Get("user_id") var role string err := h.db.QueryRow(c.Request.Context(), `SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, callerID, ).Scan(&role) if err != nil || (role != "owner" && role != "admin") { c.JSON(http.StatusForbidden, gin.H{"error": "only group owners or admins may invite members"}) return } var req struct { UserID string `json:"user_id" binding:"required"` EncryptedKey string `json:"encrypted_key"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Fetch current key version var keyVersion int h.db.QueryRow(c.Request.Context(), `SELECT key_version FROM groups WHERE id = $1`, groupID, ).Scan(&keyVersion) // Add member _, err = h.db.Exec(c.Request.Context(), ` INSERT INTO group_members (group_id, user_id, role, joined_at) VALUES ($1, $2, 'member', now()) ON CONFLICT (group_id, user_id) DO NOTHING `, groupID, req.UserID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add member"}) return } // Store their encrypted key if provided if req.EncryptedKey != "" { h.db.Exec(c.Request.Context(), ` INSERT INTO group_member_keys (group_id, user_id, key_version, encrypted_key, updated_at) VALUES ($1, $2, $3, $4, now()) ON CONFLICT (group_id, user_id, key_version) DO UPDATE SET encrypted_key = EXCLUDED.encrypted_key, updated_at = now() `, groupID, req.UserID, keyVersion, req.EncryptedKey) } c.JSON(http.StatusOK, gin.H{"message": "member invited"}) } // RemoveMember DELETE /groups/:id/members/:userId func (h *GroupsHandler) RemoveMember(c *gin.Context) { groupID := c.Param("id") targetUserID := c.Param("userId") callerID, _ := c.Get("user_id") var role string err := h.db.QueryRow(c.Request.Context(), `SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, callerID, ).Scan(&role) if err != nil || (role != "owner" && role != "admin") { c.JSON(http.StatusForbidden, gin.H{"error": "only group owners or admins may remove members"}) return } _, err = h.db.Exec(c.Request.Context(), `DELETE FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, targetUserID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove member"}) return } // Trigger automatic key rotation on next admin open h.db.Exec(c.Request.Context(), `UPDATE groups SET key_rotation_needed = true WHERE id = $1`, groupID) c.JSON(http.StatusOK, gin.H{"message": "member removed"}) } // UpdateGroupSettings PATCH /groups/:id/settings // Body: {"chat_enabled": true, "forum_enabled": false, "vault_enabled": true} func (h *GroupsHandler) UpdateGroupSettings(c *gin.Context) { groupID := c.Param("id") callerID, _ := c.Get("user_id") var role string err := h.db.QueryRow(c.Request.Context(), `SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, callerID, ).Scan(&role) if err != nil || (role != "owner" && role != "admin") { c.JSON(http.StatusForbidden, gin.H{"error": "only group owners or admins may change settings"}) return } var req struct { ChatEnabled *bool `json:"chat_enabled"` ForumEnabled *bool `json:"forum_enabled"` VaultEnabled *bool `json:"vault_enabled"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Build dynamic UPDATE (only fields provided) setClauses := []string{} args := []interface{}{} argIdx := 1 if req.ChatEnabled != nil { setClauses = append(setClauses, fmt.Sprintf("chat_enabled = $%d", argIdx)) args = append(args, *req.ChatEnabled) argIdx++ } if req.ForumEnabled != nil { setClauses = append(setClauses, fmt.Sprintf("forum_enabled = $%d", argIdx)) args = append(args, *req.ForumEnabled) argIdx++ } if req.VaultEnabled != nil { setClauses = append(setClauses, fmt.Sprintf("vault_enabled = $%d", argIdx)) args = append(args, *req.VaultEnabled) argIdx++ } if len(setClauses) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no settings provided"}) return } query := fmt.Sprintf( "UPDATE groups SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argIdx, ) args = append(args, groupID) if _, err := h.db.Exec(c.Request.Context(), query, args...); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "settings updated"}) }