sojorn/go-backend/internal/handlers/group_handler.go
2026-02-15 00:33:24 -06:00

899 lines
29 KiB
Go

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