package handlers import ( "fmt" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) type GroupHandler struct { pool *pgxpool.Pool } func NewGroupHandler(pool *pgxpool.Pool) *GroupHandler { return &GroupHandler{pool: pool} } // ═══════════════════════════════════════════════════════════════════════ // MEMBERSHIP HELPERS // ═══════════════════════════════════════════════════════════════════════ func (h *GroupHandler) requireMembership(c *gin.Context) (userID, groupID uuid.UUID, role string, ok bool) { uid, _ := c.Get("user_id") userID, err := uuid.Parse(uid.(string)) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return userID, groupID, "", false } groupID, err = uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group ID"}) return userID, groupID, "", false } err = h.pool.QueryRow(c.Request.Context(), `SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, userID).Scan(&role) if err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "not a member of this group"}) return userID, groupID, "", false } return userID, groupID, role, true } func isAdminOrOwner(role string) bool { return role == "owner" || role == "admin" } // ═══════════════════════════════════════════════════════════════════════ // GROUP POSTS (Feed) // ═══════════════════════════════════════════════════════════════════════ // ListGroupPosts returns paginated posts for a group func (h *GroupHandler) ListGroupPosts(c *gin.Context) { _, groupID, _, ok := h.requireMembership(c) if !ok { return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) if limit > 50 { limit = 50 } uid, _ := c.Get("user_id") userID, _ := uuid.Parse(uid.(string)) rows, err := h.pool.Query(c.Request.Context(), ` SELECT gp.id, gp.group_id, gp.author_id, gp.body, COALESCE(gp.image_url, '') AS image_url, gp.like_count, gp.comment_count, gp.is_pinned, gp.created_at, p.handle, COALESCE(p.display_name, '') AS display_name, COALESCE(p.avatar_url, '') AS avatar_url, EXISTS(SELECT 1 FROM group_post_likes WHERE group_post_id = gp.id AND user_id = $3) AS liked_by_me FROM group_posts gp JOIN profiles p ON p.id = gp.author_id WHERE gp.group_id = $1 AND gp.is_deleted = FALSE ORDER BY gp.is_pinned DESC, gp.created_at DESC LIMIT $2 OFFSET $4 `, groupID, limit, userID, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch posts"}) return } defer rows.Close() var posts []gin.H for rows.Next() { var id, gid, aid uuid.UUID var body, imageURL, handle, displayName, avatarURL string var likeCount, commentCount int var isPinned, likedByMe bool var createdAt time.Time if err := rows.Scan(&id, &gid, &aid, &body, &imageURL, &likeCount, &commentCount, &isPinned, &createdAt, &handle, &displayName, &avatarURL, &likedByMe); err != nil { continue } posts = append(posts, gin.H{ "id": id, "group_id": gid, "author_id": aid, "body": body, "image_url": imageURL, "like_count": likeCount, "comment_count": commentCount, "is_pinned": isPinned, "created_at": createdAt, "author_handle": handle, "author_display_name": displayName, "author_avatar_url": avatarURL, "liked_by_me": likedByMe, }) } if posts == nil { posts = []gin.H{} } c.JSON(http.StatusOK, gin.H{"posts": posts}) } // CreateGroupPost creates a new post in the group func (h *GroupHandler) CreateGroupPost(c *gin.Context) { userID, groupID, _, ok := h.requireMembership(c) if !ok { return } var req struct { Body string `json:"body"` ImageURL string `json:"image_url"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } if req.Body == "" && req.ImageURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "body or image_url required"}) return } var postID uuid.UUID var createdAt time.Time err := h.pool.QueryRow(c.Request.Context(), ` INSERT INTO group_posts (group_id, author_id, body, image_url) VALUES ($1, $2, $3, NULLIF($4, '')) RETURNING id, created_at `, groupID, userID, req.Body, req.ImageURL).Scan(&postID, &createdAt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create post"}) return } c.JSON(http.StatusCreated, gin.H{"post": gin.H{ "id": postID, "group_id": groupID, "author_id": userID, "body": req.Body, "image_url": req.ImageURL, "like_count": 0, "comment_count": 0, "created_at": createdAt, }}) } // ToggleGroupPostLike toggles a like on a group post func (h *GroupHandler) ToggleGroupPostLike(c *gin.Context) { userID, _, _, ok := h.requireMembership(c) if !ok { return } postID, err := uuid.Parse(c.Param("postId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid post ID"}) return } ctx := c.Request.Context() var exists bool h.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM group_post_likes WHERE group_post_id = $1 AND user_id = $2)`, postID, userID).Scan(&exists) if exists { h.pool.Exec(ctx, `DELETE FROM group_post_likes WHERE group_post_id = $1 AND user_id = $2`, postID, userID) h.pool.Exec(ctx, `UPDATE group_posts SET like_count = GREATEST(like_count - 1, 0) WHERE id = $1`, postID) c.JSON(http.StatusOK, gin.H{"liked": false}) } else { h.pool.Exec(ctx, `INSERT INTO group_post_likes (group_post_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, postID, userID) h.pool.Exec(ctx, `UPDATE group_posts SET like_count = like_count + 1 WHERE id = $1`, postID) c.JSON(http.StatusOK, gin.H{"liked": true}) } } // ListGroupPostComments returns comments for a group post func (h *GroupHandler) ListGroupPostComments(c *gin.Context) { _, _, _, ok := h.requireMembership(c) if !ok { return } postID, err := uuid.Parse(c.Param("postId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid post ID"}) return } rows, err := h.pool.Query(c.Request.Context(), ` SELECT gc.id, gc.post_id, gc.author_id, gc.body, gc.created_at, p.handle, COALESCE(p.display_name, '') AS display_name, COALESCE(p.avatar_url, '') AS avatar_url FROM group_post_comments gc JOIN profiles p ON p.id = gc.author_id WHERE gc.post_id = $1 AND gc.is_deleted = FALSE ORDER BY gc.created_at ASC LIMIT 100 `, postID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch comments"}) return } defer rows.Close() var comments []gin.H for rows.Next() { var id, pid, aid uuid.UUID var body, handle, displayName, avatarURL string var createdAt time.Time if err := rows.Scan(&id, &pid, &aid, &body, &createdAt, &handle, &displayName, &avatarURL); err != nil { continue } comments = append(comments, gin.H{ "id": id, "post_id": pid, "author_id": aid, "body": body, "created_at": createdAt, "author_handle": handle, "author_display_name": displayName, "author_avatar_url": avatarURL, }) } if comments == nil { comments = []gin.H{} } c.JSON(http.StatusOK, gin.H{"comments": comments}) } // CreateGroupPostComment adds a comment to a group post func (h *GroupHandler) CreateGroupPostComment(c *gin.Context) { userID, _, _, ok := h.requireMembership(c) if !ok { return } postID, err := uuid.Parse(c.Param("postId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid post ID"}) return } var req struct { Body string `json:"body"` } if err := c.ShouldBindJSON(&req); err != nil || req.Body == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "body required"}) return } ctx := c.Request.Context() var commentID uuid.UUID var createdAt time.Time err = h.pool.QueryRow(ctx, ` INSERT INTO group_post_comments (post_id, author_id, body) VALUES ($1, $2, $3) RETURNING id, created_at `, postID, userID, req.Body).Scan(&commentID, &createdAt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create comment"}) return } // Bump comment count h.pool.Exec(ctx, `UPDATE group_posts SET comment_count = comment_count + 1 WHERE id = $1`, postID) c.JSON(http.StatusCreated, gin.H{"comment": gin.H{ "id": commentID, "post_id": postID, "author_id": userID, "body": req.Body, "created_at": createdAt, }}) } // ═══════════════════════════════════════════════════════════════════════ // GROUP MESSAGES (Chat) // ═══════════════════════════════════════════════════════════════════════ // ListGroupMessages returns paginated chat messages func (h *GroupHandler) ListGroupMessages(c *gin.Context) { _, groupID, _, ok := h.requireMembership(c) if !ok { return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) if limit > 100 { limit = 100 } rows, err := h.pool.Query(c.Request.Context(), ` SELECT gm.id, gm.group_id, gm.author_id, gm.body, gm.created_at, p.handle, COALESCE(p.display_name, '') AS display_name, COALESCE(p.avatar_url, '') AS avatar_url FROM group_messages gm JOIN profiles p ON p.id = gm.author_id WHERE gm.group_id = $1 AND gm.is_deleted = FALSE ORDER BY gm.created_at DESC LIMIT $2 OFFSET $3 `, groupID, limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch messages"}) return } defer rows.Close() var messages []gin.H for rows.Next() { var id, gid, aid uuid.UUID var body, handle, displayName, avatarURL string var createdAt time.Time if err := rows.Scan(&id, &gid, &aid, &body, &createdAt, &handle, &displayName, &avatarURL); err != nil { continue } messages = append(messages, gin.H{ "id": id, "group_id": gid, "author_id": aid, "body": body, "created_at": createdAt, "author_handle": handle, "author_display_name": displayName, "author_avatar_url": avatarURL, }) } if messages == nil { messages = []gin.H{} } c.JSON(http.StatusOK, gin.H{"messages": messages}) } // SendGroupMessage sends a chat message func (h *GroupHandler) SendGroupMessage(c *gin.Context) { userID, groupID, _, ok := h.requireMembership(c) if !ok { return } var req struct { Body string `json:"body"` } if err := c.ShouldBindJSON(&req); err != nil || req.Body == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "body required"}) return } var msgID uuid.UUID var createdAt time.Time err := h.pool.QueryRow(c.Request.Context(), ` INSERT INTO group_messages (group_id, author_id, body) VALUES ($1, $2, $3) RETURNING id, created_at `, groupID, userID, req.Body).Scan(&msgID, &createdAt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send message"}) return } c.JSON(http.StatusCreated, gin.H{"message": gin.H{ "id": msgID, "group_id": groupID, "author_id": userID, "body": req.Body, "created_at": createdAt, }}) } // ═══════════════════════════════════════════════════════════════════════ // GROUP FORUM (Threads + Replies) // ═══════════════════════════════════════════════════════════════════════ // ListGroupThreads returns paginated forum threads func (h *GroupHandler) ListGroupThreads(c *gin.Context) { _, groupID, _, ok := h.requireMembership(c) if !ok { return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) if limit > 50 { limit = 50 } category := c.Query("category") rows, err := h.pool.Query(c.Request.Context(), ` SELECT t.id, t.group_id, t.author_id, t.title, t.body, t.reply_count, t.is_pinned, t.is_locked, t.last_activity_at, t.created_at, COALESCE(t.category, '') AS category, p.handle, COALESCE(p.display_name, '') AS display_name, COALESCE(p.avatar_url, '') AS avatar_url FROM group_forum_threads t JOIN profiles p ON p.id = t.author_id WHERE t.group_id = $1 AND t.is_deleted = FALSE AND ($4::text = '' OR t.category = $4) ORDER BY t.is_pinned DESC, t.last_activity_at DESC LIMIT $2 OFFSET $3 `, groupID, limit, offset, category) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch threads"}) return } defer rows.Close() var threads []gin.H for rows.Next() { var id, gid, aid uuid.UUID var title, body, cat, handle, displayName, avatarURL string var replyCount int var isPinned, isLocked bool var lastActivity, createdAt time.Time if err := rows.Scan(&id, &gid, &aid, &title, &body, &replyCount, &isPinned, &isLocked, &lastActivity, &createdAt, &cat, &handle, &displayName, &avatarURL); err != nil { continue } threads = append(threads, gin.H{ "id": id, "group_id": gid, "author_id": aid, "title": title, "body": body, "category": cat, "reply_count": replyCount, "is_pinned": isPinned, "is_locked": isLocked, "last_activity_at": lastActivity, "created_at": createdAt, "author_handle": handle, "author_display_name": displayName, "author_avatar_url": avatarURL, }) } if threads == nil { threads = []gin.H{} } c.JSON(http.StatusOK, gin.H{"threads": threads}) } // CreateGroupThread creates a new forum thread func (h *GroupHandler) CreateGroupThread(c *gin.Context) { userID, groupID, _, ok := h.requireMembership(c) if !ok { return } var req struct { Title string `json:"title"` Body string `json:"body"` Category string `json:"category"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } if req.Title == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "title required"}) return } var threadID uuid.UUID var createdAt time.Time err := h.pool.QueryRow(c.Request.Context(), ` INSERT INTO group_forum_threads (group_id, author_id, title, body, category) VALUES ($1, $2, $3, $4, NULLIF($5, '')) RETURNING id, created_at `, groupID, userID, req.Title, req.Body, req.Category).Scan(&threadID, &createdAt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create thread"}) return } c.JSON(http.StatusCreated, gin.H{"thread": gin.H{ "id": threadID, "group_id": groupID, "author_id": userID, "title": req.Title, "body": req.Body, "category": req.Category, "reply_count": 0, "created_at": createdAt, }}) } // GetGroupThread returns a single thread with its replies func (h *GroupHandler) GetGroupThread(c *gin.Context) { _, _, _, ok := h.requireMembership(c) if !ok { return } threadID, err := uuid.Parse(c.Param("threadId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid thread ID"}) return } ctx := c.Request.Context() // Get thread var id, gid, aid uuid.UUID var title, body, cat, handle, displayName, avatarURL string var replyCount int var isPinned, isLocked bool var lastActivity, createdAt time.Time err = h.pool.QueryRow(ctx, ` SELECT t.id, t.group_id, t.author_id, t.title, t.body, t.reply_count, t.is_pinned, t.is_locked, t.last_activity_at, t.created_at, COALESCE(t.category, '') AS category, p.handle, COALESCE(p.display_name, '') AS display_name, COALESCE(p.avatar_url, '') AS avatar_url FROM group_forum_threads t JOIN profiles p ON p.id = t.author_id WHERE t.id = $1 AND t.is_deleted = FALSE `, threadID).Scan(&id, &gid, &aid, &title, &body, &replyCount, &isPinned, &isLocked, &lastActivity, &createdAt, &cat, &handle, &displayName, &avatarURL) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "thread not found"}) return } thread := gin.H{ "id": id, "group_id": gid, "author_id": aid, "title": title, "body": body, "category": cat, "reply_count": replyCount, "is_pinned": isPinned, "is_locked": isLocked, "last_activity_at": lastActivity, "created_at": createdAt, "author_handle": handle, "author_display_name": displayName, "author_avatar_url": avatarURL, } // Get replies rows, err := h.pool.Query(ctx, ` SELECT r.id, r.thread_id, r.author_id, r.body, r.created_at, p.handle, COALESCE(p.display_name, '') AS display_name, COALESCE(p.avatar_url, '') AS avatar_url FROM group_forum_replies r JOIN profiles p ON p.id = r.author_id WHERE r.thread_id = $1 AND r.is_deleted = FALSE ORDER BY r.created_at ASC LIMIT 200 `, threadID) if err != nil { c.JSON(http.StatusOK, gin.H{"thread": thread, "replies": []gin.H{}}) return } defer rows.Close() var replies []gin.H for rows.Next() { var rid, tid, raid uuid.UUID var rbody, rhandle, rdisplayName, ravatarURL string var rcreatedAt time.Time if err := rows.Scan(&rid, &tid, &raid, &rbody, &rcreatedAt, &rhandle, &rdisplayName, &ravatarURL); err != nil { continue } replies = append(replies, gin.H{ "id": rid, "thread_id": tid, "author_id": raid, "body": rbody, "created_at": rcreatedAt, "author_handle": rhandle, "author_display_name": rdisplayName, "author_avatar_url": ravatarURL, }) } if replies == nil { replies = []gin.H{} } c.JSON(http.StatusOK, gin.H{"thread": thread, "replies": replies}) } // CreateGroupThreadReply adds a reply to a forum thread func (h *GroupHandler) CreateGroupThreadReply(c *gin.Context) { userID, _, _, ok := h.requireMembership(c) if !ok { return } threadID, err := uuid.Parse(c.Param("threadId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid thread ID"}) return } var req struct { Body string `json:"body"` } if err := c.ShouldBindJSON(&req); err != nil || req.Body == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "body required"}) return } ctx := c.Request.Context() // Check thread exists and not locked var isLocked bool err = h.pool.QueryRow(ctx, `SELECT is_locked FROM group_forum_threads WHERE id = $1 AND is_deleted = FALSE`, threadID).Scan(&isLocked) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "thread not found"}) return } if isLocked { c.JSON(http.StatusForbidden, gin.H{"error": "thread is locked"}) return } var replyID uuid.UUID var createdAt time.Time err = h.pool.QueryRow(ctx, ` INSERT INTO group_forum_replies (thread_id, author_id, body) VALUES ($1, $2, $3) RETURNING id, created_at `, threadID, userID, req.Body).Scan(&replyID, &createdAt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create reply"}) return } // Bump reply count and last_activity h.pool.Exec(ctx, ` UPDATE group_forum_threads SET reply_count = reply_count + 1, last_activity_at = NOW() WHERE id = $1 `, threadID) c.JSON(http.StatusCreated, gin.H{"reply": gin.H{ "id": replyID, "thread_id": threadID, "author_id": userID, "body": req.Body, "created_at": createdAt, }}) } // ═══════════════════════════════════════════════════════════════════════ // MEMBERS // ═══════════════════════════════════════════════════════════════════════ // ListGroupMembers returns all members of a group func (h *GroupHandler) ListGroupMembers(c *gin.Context) { _, groupID, _, ok := h.requireMembership(c) if !ok { return } rows, err := h.pool.Query(c.Request.Context(), ` SELECT gm.user_id, gm.role, gm.joined_at, p.handle, COALESCE(p.display_name, '') AS display_name, COALESCE(p.avatar_url, '') AS avatar_url FROM group_members gm JOIN profiles p ON p.id = gm.user_id WHERE gm.group_id = $1 ORDER BY CASE gm.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 WHEN 'moderator' THEN 2 ELSE 3 END, gm.joined_at ASC `, groupID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch members"}) return } defer rows.Close() var members []gin.H for rows.Next() { var uid uuid.UUID var role, handle, displayName, avatarURL string var joinedAt time.Time if err := rows.Scan(&uid, &role, &joinedAt, &handle, &displayName, &avatarURL); err != nil { continue } members = append(members, gin.H{ "user_id": uid, "role": role, "joined_at": joinedAt, "handle": handle, "display_name": displayName, "avatar_url": avatarURL, }) } if members == nil { members = []gin.H{} } c.JSON(http.StatusOK, gin.H{"members": members}) } // RemoveGroupMember removes a member (owner/admin only) func (h *GroupHandler) RemoveGroupMember(c *gin.Context) { _, groupID, role, ok := h.requireMembership(c) if !ok { return } if !isAdminOrOwner(role) { c.JSON(http.StatusForbidden, gin.H{"error": "only owner or admin can remove members"}) return } targetID, err := uuid.Parse(c.Param("memberId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid member ID"}) return } ctx := c.Request.Context() // Can't remove owner var targetRole string err = h.pool.QueryRow(ctx, `SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, targetID).Scan(&targetRole) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "member not found"}) return } if targetRole == "owner" { c.JSON(http.StatusForbidden, gin.H{"error": "cannot remove the group owner"}) return } _, err = h.pool.Exec(ctx, `DELETE FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, targetID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove member"}) return } // Update member count h.pool.Exec(ctx, `UPDATE groups SET member_count = (SELECT COUNT(*) FROM group_members WHERE group_id = $1) WHERE id = $1`, groupID) c.JSON(http.StatusOK, gin.H{"status": "removed"}) } // UpdateMemberRole changes a member's role (owner only) func (h *GroupHandler) UpdateMemberRole(c *gin.Context) { _, groupID, role, ok := h.requireMembership(c) if !ok { return } if role != "owner" { c.JSON(http.StatusForbidden, gin.H{"error": "only the owner can change roles"}) return } targetID, err := uuid.Parse(c.Param("memberId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid member ID"}) return } var req struct { Role string `json:"role"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } if req.Role != "admin" && req.Role != "moderator" && req.Role != "member" { c.JSON(http.StatusBadRequest, gin.H{"error": "role must be admin, moderator, or member"}) return } _, err = h.pool.Exec(c.Request.Context(), `UPDATE group_members SET role = $3 WHERE group_id = $1 AND user_id = $2 AND role != 'owner'`, groupID, targetID, req.Role) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"}) return } c.JSON(http.StatusOK, gin.H{"status": "updated", "new_role": req.Role}) } // LeaveGroup removes the current user from the group func (h *GroupHandler) LeaveGroup(c *gin.Context) { userID, groupID, role, ok := h.requireMembership(c) if !ok { return } if role == "owner" { c.JSON(http.StatusForbidden, gin.H{"error": "owner cannot leave; transfer ownership first or delete the group"}) return } ctx := c.Request.Context() _, err := h.pool.Exec(ctx, `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 } h.pool.Exec(ctx, `UPDATE groups SET member_count = (SELECT COUNT(*) FROM group_members WHERE group_id = $1) WHERE id = $1`, groupID) c.JSON(http.StatusOK, gin.H{"status": "left"}) } // ═══════════════════════════════════════════════════════════════════════ // GROUP SETTINGS // ═══════════════════════════════════════════════════════════════════════ // UpdateGroup updates group name, description, or settings func (h *GroupHandler) UpdateGroup(c *gin.Context) { _, groupID, role, ok := h.requireMembership(c) if !ok { return } if !isAdminOrOwner(role) { c.JSON(http.StatusForbidden, gin.H{"error": "only owner or admin can update group settings"}) return } var req struct { Name *string `json:"name"` Description *string `json:"description"` Settings *string `json:"settings"` // JSON string } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } ctx := c.Request.Context() if req.Name != nil && *req.Name != "" { h.pool.Exec(ctx, `UPDATE groups SET name = $2, updated_at = NOW() WHERE id = $1`, groupID, *req.Name) } if req.Description != nil { h.pool.Exec(ctx, `UPDATE groups SET description = $2, updated_at = NOW() WHERE id = $1`, groupID, *req.Description) } if req.Settings != nil { h.pool.Exec(ctx, `UPDATE groups SET settings = $2::jsonb, updated_at = NOW() WHERE id = $1`, groupID, *req.Settings) } c.JSON(http.StatusOK, gin.H{"status": "updated"}) } // DeleteGroup permanently deletes a group (owner only) func (h *GroupHandler) DeleteGroup(c *gin.Context) { _, groupID, role, ok := h.requireMembership(c) if !ok { return } if role != "owner" { c.JSON(http.StatusForbidden, gin.H{"error": "only the owner can delete a group"}) return } ctx := c.Request.Context() _, err := h.pool.Exec(ctx, `UPDATE groups SET is_active = FALSE, updated_at = NOW() WHERE id = $1`, groupID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete group"}) return } c.JSON(http.StatusOK, gin.H{"status": "deleted"}) } // InviteToGroup adds a member to a non-encrypted group func (h *GroupHandler) InviteToGroup(c *gin.Context) { _, groupID, role, ok := h.requireMembership(c) if !ok { return } if !isAdminOrOwner(role) { c.JSON(http.StatusForbidden, gin.H{"error": "only owner or admin can invite"}) return } var req struct { UserID string `json:"user_id"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } inviteeID, err := uuid.Parse(req.UserID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user_id"}) return } ctx := c.Request.Context() // Check user exists var exists bool h.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM profiles WHERE id = $1)`, inviteeID).Scan(&exists) if !exists { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } _, err = h.pool.Exec(ctx, ` INSERT INTO group_members (group_id, user_id, role) VALUES ($1, $2, 'member') ON CONFLICT (group_id, user_id) DO NOTHING `, groupID, inviteeID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to invite"}) return } h.pool.Exec(ctx, `UPDATE groups SET member_count = (SELECT COUNT(*) FROM group_members WHERE group_id = $1) WHERE id = $1`, groupID) c.JSON(http.StatusOK, gin.H{"status": "invited"}) } // SearchUsersForInvite searches for users by handle to invite func (h *GroupHandler) SearchUsersForInvite(c *gin.Context) { _, groupID, _, ok := h.requireMembership(c) if !ok { return } query := c.Query("q") if len(query) < 2 { c.JSON(http.StatusBadRequest, gin.H{"error": "query must be at least 2 characters"}) return } rows, err := h.pool.Query(c.Request.Context(), ` SELECT p.id, p.handle, COALESCE(p.display_name, '') AS display_name, COALESCE(p.avatar_url, '') AS avatar_url FROM profiles p WHERE (p.handle ILIKE $1 OR p.display_name ILIKE $1) AND p.id NOT IN (SELECT user_id FROM group_members WHERE group_id = $2) LIMIT 20 `, fmt.Sprintf("%%%s%%", query), groupID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) return } defer rows.Close() var users []gin.H for rows.Next() { var uid uuid.UUID var handle, displayName, avatarURL string if err := rows.Scan(&uid, &handle, &displayName, &avatarURL); err != nil { continue } users = append(users, gin.H{ "id": uid, "handle": handle, "display_name": displayName, "avatar_url": avatarURL, }) } if users == nil { users = []gin.H{} } c.JSON(http.StatusOK, gin.H{"users": users}) }