899 lines
29 KiB
Go
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})
|
|
}
|