package handlers // persona_groups.go — Persona group (roster template) CRUD (v0.23.2) // // Persona groups are saved collections of personas used as templates // for creating group chats. Each member has an is_leader flag. // // Routes: // GET /api/v1/persona-groups // POST /api/v1/persona-groups // GET /api/v1/persona-groups/:id // PUT /api/v1/persona-groups/:id // DELETE /api/v1/persona-groups/:id // POST /api/v1/persona-groups/:id/members // DELETE /api/v1/persona-groups/:id/members/:memberId import ( "database/sql" "net/http" "strings" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) type PersonaGroupHandler struct{} func NewPersonaGroupHandler() *PersonaGroupHandler { return &PersonaGroupHandler{} } // ── List ──────────────────────────────────────── func (h *PersonaGroupHandler) List(c *gin.Context) { userID := getUserID(c) rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at FROM persona_groups WHERE owner_id = $1 ORDER BY name `), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groups"}) return } defer rows.Close() groups := []models.PersonaGroup{} for rows.Next() { var g models.PersonaGroup if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)); err != nil { continue } groups = append(groups, g) } // Load members for each group for i := range groups { groups[i].Members = h.loadMembers(c, groups[i].ID) } c.JSON(http.StatusOK, gin.H{"data": groups}) } // ── Get ───────────────────────────────────────── func (h *PersonaGroupHandler) Get(c *gin.Context) { userID := getUserID(c) id := c.Param("id") var g models.PersonaGroup err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at FROM persona_groups WHERE id = $1 AND owner_id = $2 `), id, userID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)) 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 get group"}) return } g.Members = h.loadMembers(c, g.ID) if g.Members == nil { g.Members = []models.PersonaGroupMember{} } c.JSON(http.StatusOK, g) } // ── Create ────────────────────────────────────── func (h *PersonaGroupHandler) Create(c *gin.Context) { userID := getUserID(c) var req struct { Name string `json:"name" binding:"required,min=1,max=100"` Description string `json:"description"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var g models.PersonaGroup if database.IsSQLite() { id := store.NewID() _, err := database.DB.ExecContext(c.Request.Context(), ` INSERT INTO persona_groups (id, name, description, owner_id, scope) VALUES (?, ?, ?, ?, 'personal') `, id, strings.TrimSpace(req.Name), req.Description, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"}) return } database.DB.QueryRowContext(c.Request.Context(), ` SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at FROM persona_groups WHERE id = ? `, id).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)) } else { err := database.DB.QueryRowContext(c.Request.Context(), ` INSERT INTO persona_groups (name, description, owner_id, scope) VALUES ($1, $2, $3, 'personal') RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at `, strings.TrimSpace(req.Name), req.Description, userID).Scan( &g.ID, &g.Name, &g.Description, &g.OwnerID, &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"}) return } } g.Members = []models.PersonaGroupMember{} c.JSON(http.StatusCreated, g) } // ── Update ────────────────────────────────────── func (h *PersonaGroupHandler) Update(c *gin.Context) { userID := getUserID(c) id := c.Param("id") var req struct { Name *string `json:"name"` Description *string `json:"description"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Verify ownership var ownerID string err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` SELECT owner_id FROM persona_groups WHERE id = $1 `), id).Scan(&ownerID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) return } if ownerID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "not your group"}) return } if req.Name != nil { database.DB.ExecContext(c.Request.Context(), database.Q(` UPDATE persona_groups SET name = $1, updated_at = NOW() WHERE id = $2 `), strings.TrimSpace(*req.Name), id) } if req.Description != nil { database.DB.ExecContext(c.Request.Context(), database.Q(` UPDATE persona_groups SET description = $1, updated_at = NOW() WHERE id = $2 `), *req.Description, id) } c.JSON(http.StatusOK, gin.H{"ok": true}) } // ── Delete ────────────────────────────────────── func (h *PersonaGroupHandler) Delete(c *gin.Context) { userID := getUserID(c) id := c.Param("id") result, err := database.DB.ExecContext(c.Request.Context(), database.Q(` DELETE FROM persona_groups WHERE id = $1 AND owner_id = $2 `), id, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) return } n, _ := result.RowsAffected() if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // ── Add Member ────────────────────────────────── func (h *PersonaGroupHandler) AddMember(c *gin.Context) { userID := getUserID(c) groupID := c.Param("id") var req struct { PersonaID string `json:"persona_id" binding:"required"` IsLeader bool `json:"is_leader"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Verify ownership var ownerID string err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` SELECT owner_id FROM persona_groups WHERE id = $1 `), groupID).Scan(&ownerID) if err != nil || ownerID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "not your group"}) return } // If setting as leader, clear existing leader if req.IsLeader { database.DB.ExecContext(c.Request.Context(), database.Q(` UPDATE persona_group_members SET is_leader = false WHERE group_id = $1 `), groupID) } if database.IsSQLite() { id := store.NewID() _, err = database.DB.ExecContext(c.Request.Context(), ` INSERT INTO persona_group_members (id, group_id, persona_id, is_leader) VALUES (?, ?, ?, ?) ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = excluded.is_leader `, id, groupID, req.PersonaID, req.IsLeader) } else { _, err = database.DB.ExecContext(c.Request.Context(), ` INSERT INTO persona_group_members (group_id, persona_id, is_leader) VALUES ($1, $2, $3) ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = EXCLUDED.is_leader `, groupID, req.PersonaID, req.IsLeader) } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"}) return } c.JSON(http.StatusCreated, gin.H{"ok": true}) } // ── Remove Member ─────────────────────────────── func (h *PersonaGroupHandler) RemoveMember(c *gin.Context) { userID := getUserID(c) groupID := c.Param("id") memberID := c.Param("memberId") // Verify ownership var ownerID string err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` SELECT owner_id FROM persona_groups WHERE id = $1 `), groupID).Scan(&ownerID) if err != nil || ownerID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "not your group"}) return } database.DB.ExecContext(c.Request.Context(), database.Q(` DELETE FROM persona_group_members WHERE id = $1 AND group_id = $2 `), memberID, groupID) c.JSON(http.StatusOK, gin.H{"ok": true}) } // ── Helpers ───────────────────────────────────── func (h *PersonaGroupHandler) loadMembers(c *gin.Context, groupID string) []models.PersonaGroupMember { rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order, COALESCE(p.name, '') AS persona_name, COALESCE(p.handle, '') AS persona_handle, COALESCE(p.avatar, '') AS persona_avatar FROM persona_group_members pgm LEFT JOIN personas p ON p.id = pgm.persona_id WHERE pgm.group_id = $1 ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id `), groupID) if err != nil { return []models.PersonaGroupMember{} } defer rows.Close() members := []models.PersonaGroupMember{} for rows.Next() { var m models.PersonaGroupMember if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder, &m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil { continue } members = append(members, m) } return members }