sojorn/go-backend/internal/handlers/groups_handler.go
Patrick Britton 93a2c45a92 feat: Reaction system for Quips feed + fix groups 500 + reduce jank
- Replace heart/like in Quips sidebar with full reaction system:
  tap = quick ❤️, long-press = full ReactionPicker dialog
- Add reactionPackageProvider (CDN → local assets → emoji fallback)
- Switch ReactionPicker to ConsumerStatefulWidget using provider
- Add CachedNetworkImage support in ReactionPicker + _ReactionIcon
- Fix CreateGroup handler: use 'privacy' column, drop non-existent
  'is_private'/'banner_url' columns (were causing 500 on group creation)
- Cache overlayJson parsing in QuipVideoItem initState/didUpdateWidget
  to eliminate double jsonDecode per build frame (was causing 174ms jank)
- Add post_hides table + HidePost handler + feed filtering
- Add showNavActions param to TraditionalQuipsSheet for clean Quips header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 08:11:08 -06:00

916 lines
28 KiB
Go

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