Changeset 0.29.0 (#195)

This commit is contained in:
2026-03-17 16:28:47 +00:00
parent 128cbb8174
commit 5d637d3a90
129 changed files with 9418 additions and 3016 deletions

View File

@@ -2,64 +2,40 @@ 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
// v0.29.0: Raw SQL replaced with PersonaGroupStore methods.
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{}
type PersonaGroupHandler struct {
stores store.Stores
}
func NewPersonaGroupHandler() *PersonaGroupHandler { return &PersonaGroupHandler{} }
func NewPersonaGroupHandler(s store.Stores) *PersonaGroupHandler {
return &PersonaGroupHandler{stores: s}
}
// ── 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)
groups, err := h.stores.PersonaGroups.List(c.Request.Context(), 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)
members, _ := h.stores.PersonaGroups.ListMembers(c.Request.Context(), groups[i].ID)
groups[i].Members = members
}
c.JSON(http.StatusOK, gin.H{"data": groups})
@@ -71,25 +47,22 @@ 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
}
g, err := h.stores.PersonaGroups.Get(c.Request.Context(), id, userID)
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{}
if g == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
members, _ := h.stores.PersonaGroups.ListMembers(c.Request.Context(), g.ID)
if members == nil {
members = []models.PersonaGroupMember{}
}
g.Members = members
c.JSON(http.StatusOK, g)
}
@@ -107,34 +80,16 @@ func (h *PersonaGroupHandler) Create(c *gin.Context) {
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 := &models.PersonaGroup{
Name: strings.TrimSpace(req.Name),
Description: req.Description,
OwnerID: userID,
Scope: "personal",
}
if err := h.stores.PersonaGroups.Create(c.Request.Context(), g); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
return
}
g.Members = []models.PersonaGroupMember{}
@@ -157,11 +112,8 @@ func (h *PersonaGroupHandler) Update(c *gin.Context) {
}
// 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 {
ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), id)
if err != nil || ownerID == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
@@ -170,15 +122,15 @@ func (h *PersonaGroupHandler) Update(c *gin.Context) {
return
}
fields := map[string]interface{}{}
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)
fields["name"] = strings.TrimSpace(*req.Name)
}
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)
fields["description"] = *req.Description
}
if len(fields) > 0 {
_ = h.stores.PersonaGroups.Update(c.Request.Context(), id, fields)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
@@ -190,14 +142,11 @@ 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)
n, err := h.stores.PersonaGroups.Delete(c.Request.Context(), 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
@@ -221,37 +170,13 @@ func (h *PersonaGroupHandler) AddMember(c *gin.Context) {
}
// 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)
ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), groupID)
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 {
if err := h.stores.PersonaGroups.AddMember(c.Request.Context(), groupID, req.PersonaID, req.IsLeader); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"})
return
}
@@ -266,49 +191,12 @@ func (h *PersonaGroupHandler) RemoveMember(c *gin.Context) {
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)
ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), groupID)
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)
_ = h.stores.PersonaGroups.RemoveMember(c.Request.Context(), 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
}