Changeset 0.37.14 (#226)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-23 16:47:48 +00:00
committed by xcaliber
parent fcb998bff9
commit b7746c3004
164 changed files with 6972 additions and 3527 deletions

View File

@@ -65,7 +65,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"users": users, "total": total})
c.JSON(http.StatusOK, gin.H{"data": users, "total": total})
}
func (h *AdminHandler) CreateUser(c *gin.Context) {
@@ -399,11 +399,11 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
hasAdminPrompt = true
}
// Channel retention mode (v0.23.2 — flexible or retain)
retentionMode := "flexible"
if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil {
if mode, ok := retCfg["mode"].(string); ok && mode != "" {
retentionMode = mode
// Retention TTL (v0.37.14 — days before purge for global/team channels)
retentionTTL := 0
if ttlCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "retention_ttl_days"); err == nil {
if v, ok := ttlCfg["value"].(float64); ok {
retentionTTL = int(v)
}
}
@@ -417,7 +417,7 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
"allow_registration": policies["allow_registration"],
"allow_user_byok": policies["allow_user_byok"],
"allow_user_personas": policies["allow_user_personas"],
"channel_retention_mode": retentionMode,
"retention_ttl_days": retentionTTL,
},
})
}
@@ -459,7 +459,7 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
HasKey: cfg.HasKey(),
}
}
c.JSON(http.StatusOK, gin.H{"configs": out})
c.JSON(http.StatusOK, gin.H{"data": out})
}
func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
@@ -601,7 +601,7 @@ func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return
}
c.JSON(http.StatusOK, gin.H{"models": entries})
c.JSON(http.StatusOK, gin.H{"data": entries})
}
func (h *AdminHandler) FetchModels(c *gin.Context) {
@@ -802,19 +802,19 @@ func (h *AdminHandler) ListArchivedChannels(c *gin.Context) {
}
// Retention config
retentionMode := "flexible"
if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil {
if mode, ok := retCfg["mode"].(string); ok {
retentionMode = mode
retentionTTL := 0
if ttlCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "retention_ttl_days"); err == nil {
if v, ok := ttlCfg["value"].(float64); ok {
retentionTTL = int(v)
}
}
c.JSON(http.StatusOK, gin.H{
"channels": channels,
"total": total,
"page": page,
"per_page": perPage,
"retention_mode": retentionMode,
"data": channels,
"total": total,
"page": page,
"per_page": perPage,
"retention_ttl_days": retentionTTL,
})
}

View File

@@ -64,7 +64,7 @@ func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"configs": out})
c.JSON(http.StatusOK, gin.H{"data": out})
}
// GetConfig returns a single config by ID (if user has access).
@@ -248,7 +248,7 @@ func (h *ProviderConfigHandler) ListModels(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"models": entries})
c.JSON(http.StatusOK, gin.H{"data": entries})
}
// FetchModels fetches models from the provider API and auto-enables them.

View File

@@ -154,6 +154,9 @@ func (h *AuthHandler) Logout(c *gin.Context) {
h.uekCache.Evict(userID.(string))
}
// Clear the sb_token cookie so SSR middleware doesn't trust a stale token
c.SetCookie("sb_token", "", -1, "/", "", false, false)
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}

View File

@@ -46,7 +46,7 @@ func (h *ChannelModelHandler) List(c *gin.Context) {
if roster == nil {
roster = []models.ChannelModel{}
}
c.JSON(http.StatusOK, gin.H{"models": roster})
c.JSON(http.StatusOK, gin.H{"data": roster})
}
// ── Add ──────────────────────────────────────
@@ -110,7 +110,7 @@ func (h *ChannelModelHandler) Add(c *gin.Context) {
// Return the updated roster
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusCreated, gin.H{"models": roster})
c.JSON(http.StatusCreated, gin.H{"data": roster})
}
// ── Update ───────────────────────────────────
@@ -177,7 +177,7 @@ func (h *ChannelModelHandler) Update(c *gin.Context) {
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusOK, gin.H{"models": roster})
c.JSON(http.StatusOK, gin.H{"data": roster})
}
// ── Delete ───────────────────────────────────
@@ -222,7 +222,7 @@ func (h *ChannelModelHandler) Delete(c *gin.Context) {
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusOK, gin.H{"models": roster})
c.JSON(http.StatusOK, gin.H{"data": roster})
}
// ── Helpers ──────────────────────────────────

View File

@@ -1,14 +1,19 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"chat-switchboard/database"
"chat-switchboard/models"
"chat-switchboard/store"
)
@@ -218,6 +223,9 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
channels = append(channels, listItemToResponse(item))
}
// Resolve DM titles: show the other participant's name, not the creator's label
resolveDMTitles(c.Request.Context(), channels, userID)
SafeJSON(c, http.StatusOK, paginatedResponse{
Data: channels,
Page: page,
@@ -346,15 +354,29 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
return
}
// Verify ownership
// Verify ownership (participants can only move to folder)
owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if !owns {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
// Participants may move channels to their own folders
folderOnly := req.FolderID != nil && req.Title == nil && req.Description == nil &&
req.Model == nil && req.SystemPrompt == nil && req.ProviderConfigID == nil &&
req.IsArchived == nil && req.IsPinned == nil && req.Folder == nil &&
req.Tags == nil && req.WorkspaceID == nil && req.AiMode == nil &&
req.Topic == nil && req.Settings == nil
if !folderOnly {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
// Verify they are a participant
canAccess, _ := h.stores.Channels.UserCanAccess(ctx, channelID, userID)
if !canAccess {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
}
// Build fields map for store.Update
@@ -455,18 +477,51 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
ctx := c.Request.Context()
n, err := h.stores.Channels.DeleteByOwner(c.Request.Context(), channelID, userID)
// Check ownership first (without deleting)
owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if n == 0 {
if !owns {
// Not the owner — if participant, leave the channel instead of deleting.
res, _ := database.DB.ExecContext(ctx, database.Q(`
DELETE FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2
`), channelID, userID)
if rows, _ := res.RowsAffected(); rows > 0 {
c.JSON(http.StatusOK, gin.H{"message": "left channel"})
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
// Clean up storage files (CASCADE already removed PG file rows)
// Check if retention policy applies (global/team provider + TTL > 0)
if h.shouldRetain(ctx, channelID) {
ttl := h.retentionTTL(ctx)
purgeAfter := time.Now().Add(time.Duration(ttl) * 24 * time.Hour)
if err := h.stores.Channels.ArchiveForRetention(ctx, channelID, purgeAfter); err != nil {
log.Printf("[retention] ArchiveForRetention(%s) error: %v", channelID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to archive channel"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "channel archived for retention",
"purge_after": purgeAfter.UTC().Format("2006-01-02T15:04:05Z"),
})
return
}
// Exempt — hard delete
n, err := h.stores.Channels.DeleteByOwner(ctx, channelID, userID)
if err != nil || n == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
return
}
if channelDeleteHook != nil {
go channelDeleteHook(channelID)
}
@@ -474,6 +529,45 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
}
// shouldRetain returns true if a channel uses a global or team provider
// and the retention TTL is configured (> 0).
func (h *ChannelHandler) shouldRetain(ctx context.Context, channelID string) bool {
ttl := h.retentionTTL(ctx)
if ttl <= 0 {
return false
}
// Only BYOK (personal) provider channels are exempt.
// All others — global, team, or no explicit provider — follow retention.
var providerConfigID *string
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT provider_config_id FROM channels WHERE id = $1
`), channelID).Scan(&providerConfigID)
if providerConfigID == nil || *providerConfigID == "" {
return true // no explicit provider → defaults to global → retain
}
cfg, err := h.stores.Providers.GetByID(ctx, *providerConfigID)
if err != nil {
return true // provider deleted (FK SET NULL already handled above) → retain
}
return cfg.Scope != models.ScopePersonal
}
// retentionTTL reads the configured retention TTL in days from global settings.
func (h *ChannelHandler) retentionTTL(ctx context.Context) int {
cfg, err := h.stores.GlobalConfig.Get(ctx, "retention_ttl_days")
if err != nil {
return 0
}
if v, ok := cfg["value"].(float64); ok {
return int(v)
}
return 0
}
// ── Mark Read (v0.23.2) ───────────────────────
func (h *ChannelHandler) MarkRead(c *gin.Context) {
@@ -487,3 +581,60 @@ func (h *ChannelHandler) MarkRead(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── DM Title Resolution ──────────────────────
// resolveDMTitles replaces DM channel titles with the other participant's
// display name so each user sees "DM <other_person>" instead of the
// creator's original label.
func resolveDMTitles(ctx context.Context, channels []channelResponse, viewerID string) {
// Collect DM channel IDs
var dmIDs []string
dmIdx := map[string]int{} // channel_id → index in channels slice
for i, ch := range channels {
if ch.Type == "dm" {
dmIDs = append(dmIDs, ch.ID)
dmIdx[ch.ID] = i
}
}
if len(dmIDs) == 0 {
return
}
// Query: for each DM, get the OTHER participant's display name
// Uses channel_participants to find the peer, then joins users for name
placeholders := make([]string, len(dmIDs))
args := make([]interface{}, 0, len(dmIDs)+1)
for i, id := range dmIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args = append(args, id)
}
args = append(args, viewerID)
viewerPlaceholder := fmt.Sprintf("$%d", len(args))
query := database.Q(fmt.Sprintf(`
SELECT cp.channel_id,
COALESCE(NULLIF(u.display_name, ''), u.username, 'Unknown')
FROM channel_participants cp
JOIN users u ON u.id = cp.participant_id
WHERE cp.channel_id IN (%s)
AND cp.participant_type = 'user'
AND cp.participant_id != %s
LIMIT %d
`, strings.Join(placeholders, ","), viewerPlaceholder, len(dmIDs)))
rows, err := database.DB.QueryContext(ctx, query, args...)
if err != nil {
return // non-fatal: keep original titles
}
defer rows.Close()
for rows.Next() {
var chID, peerName string
if rows.Scan(&chID, &peerName) == nil {
if idx, ok := dmIdx[chID]; ok {
channels[idx].Title = "DM " + peerName
}
}
}
}

View File

@@ -267,46 +267,14 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1
`), channelID).Scan(&aiMode)
if aiMode == "off" {
c.JSON(http.StatusForbidden, gin.H{"error": "AI responses are disabled for this channel"})
return
}
if aiMode == "mention_only" && extractFirstMention(req.Content) == "" {
if aiMode == "off" || (aiMode == "mention_only" && extractFirstMention(req.Content) == "") {
// Persist the user message before returning
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "")
if err != nil {
log.Printf("[mention_only] Failed to persist user message: %v", err)
log.Printf("[ai_skip] Failed to persist user message: %v", err)
}
// Broadcast via WS to ALL user participants (not just sender)
if h.hub != nil && msgID != "" {
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
"role": "user",
"content": req.Content,
"user_id": userID,
})
evt := events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
// Send to sender
h.hub.PublishToUser(userID, evt)
// Send to other user participants in the channel
pRows, pErr := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2
`), channelID, userID)
if pErr == nil {
defer pRows.Close()
for pRows.Next() {
var pid string
if pRows.Scan(&pid) == nil {
h.hub.PublishToUser(pid, evt)
}
}
}
broadcastUserMessage(c.Request.Context(), h.hub, channelID, msgID, req.Content, userID)
}
c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true, "message_id": msgID})
return
@@ -532,6 +500,11 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
log.Printf("Failed to persist user message: %v", err)
}
// Broadcast user message to all channel participants
if h.hub != nil && msgID != "" {
broadcastUserMessage(c.Request.Context(), h.hub, channelID, msgID, req.Content, userID)
}
// Link files to the persisted message
if msgID != "" && len(req.FileIDs) > 0 {
for _, fID := range req.FileIDs {
@@ -963,7 +936,7 @@ func (h *CompletionHandler) ListTools(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"tools": result})
c.JSON(http.StatusOK, gin.H{"data": result})
}
// ── Streaming Completion (SSE) with Tool Loop ──
@@ -985,10 +958,16 @@ func (h *CompletionHandler) streamCompletion(
// Persist assistant response
if result.Content != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID); err != nil {
asstMsgID, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID)
if err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
// Broadcast assistant message to other channel participants
if h.hub != nil && asstMsgID != "" {
broadcastAssistantMessage(c.Request.Context(), h.hub, channelID, asstMsgID, result.Content, model, userID, personaID)
}
// AI-to-AI chaining: if the response @mentions another persona participant,
// trigger a follow-up completion asynchronously via WebSocket delivery.
go func() {

View File

@@ -160,26 +160,9 @@ func (h *CompletionHandler) chainIfMentioned(
return
}
// Deliver via WebSocket
// Deliver via WebSocket to all channel participants
if h.hub != nil {
payload, _ := json.Marshal(map[string]interface{}{
"id": msgID,
"channel_id": channelID,
"role": "assistant",
"content": resp.Content,
"model": model,
"participant_type": "persona",
"participant_id": mentionPersona.ID,
"display_name": mentionPersona.Name,
"avatar": mentionPersona.Avatar,
"tokens_used": resp.InputTokens + resp.OutputTokens,
"chain_depth": depth + 1,
})
h.hub.PublishToUser(userID, events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
broadcastAssistantMessage(context.Background(), h.hub, channelID, msgID, resp.Content, model, "", mentionPersona.ID)
}
// Log usage
@@ -301,24 +284,7 @@ func (h *CompletionHandler) chainToPersona(channelID, userID, personaID, trigger
}
if h.hub != nil {
payload, _ := json.Marshal(map[string]interface{}{
"id": msgID,
"channel_id": channelID,
"role": "assistant",
"content": resp.Content,
"model": model,
"participant_type": "persona",
"participant_id": persona.ID,
"display_name": persona.Name,
"avatar": persona.Avatar,
"tokens_used": resp.InputTokens + resp.OutputTokens,
"chain_depth": depth,
})
h.hub.PublishToUser(userID, events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
broadcastAssistantMessage(context.Background(), h.hub, channelID, msgID, resp.Content, model, "", persona.ID)
}
if h.stores.Usage != nil {

View File

@@ -5,6 +5,7 @@ package handlers
// v0.29.0: Raw SQL replaced with FolderStore methods.
import (
"encoding/json"
"net/http"
"strings"
@@ -30,14 +31,15 @@ func (h *FolderHandler) List(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
return
}
c.JSON(http.StatusOK, gin.H{"folders": folders})
c.JSON(http.StatusOK, gin.H{"data": folders})
}
func (h *FolderHandler) Create(c *gin.Context) {
userID := getUserID(c)
var req struct {
Name string `json:"name" binding:"required"`
SortOrder int `json:"sort_order"`
Name string `json:"name" binding:"required"`
ParentID *string `json:"parent_id"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -52,6 +54,7 @@ func (h *FolderHandler) Create(c *gin.Context) {
f := &models.Folder{
UserID: userID,
Name: req.Name,
ParentID: req.ParentID,
SortOrder: req.SortOrder,
}
if err := h.stores.Folders.Create(c.Request.Context(), f); err != nil {
@@ -64,11 +67,19 @@ func (h *FolderHandler) Create(c *gin.Context) {
func (h *FolderHandler) Update(c *gin.Context) {
userID := getUserID(c)
folderID := c.Param("id")
var req struct {
Name string `json:"name"`
SortOrder *int `json:"sort_order"`
// Read raw body to detect which fields were explicitly sent
data, err := c.GetRawData()
if err != nil || len(data) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if err := c.ShouldBindJSON(&req); err != nil {
var req struct {
Name string `json:"name"`
SortOrder *int `json:"sort_order"`
ParentID *string `json:"parent_id"`
}
if err := json.Unmarshal(data, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -76,7 +87,15 @@ func (h *FolderHandler) Update(c *gin.Context) {
req.Name = strings.TrimSpace(req.Name)
}
n, err := h.stores.Folders.Update(c.Request.Context(), folderID, userID, req.Name, req.SortOrder)
// Only update parent_id if it was explicitly present in the JSON
var parentID **string
var raw map[string]json.RawMessage
_ = json.Unmarshal(data, &raw)
if _, ok := raw["parent_id"]; ok {
parentID = &req.ParentID
}
n, err := h.stores.Folders.Update(c.Request.Context(), folderID, userID, req.Name, req.SortOrder, parentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update folder"})
return

View File

@@ -252,6 +252,10 @@ func setupHarness(t *testing.T) *testHarness {
permH := NewProfilePermissionsHandler(stores)
protected.GET("/profile/permissions", permH.GetMyPermissions)
// Boot payload (v0.37.15)
bootH := NewProfileBootstrapHandler(stores)
protected.GET("/profile/bootstrap", bootH.GetBootstrap)
// Profile / Settings
settings := NewSettingsHandler(stores, nil)
protected.GET("/profile", settings.GetProfile)
@@ -518,6 +522,10 @@ func (h *testHarness) registerUser(username, email, password string) (userID, to
var resp map[string]interface{}
decode(w, &resp)
token, _ = resp["access_token"].(string)
if token == "" {
h.t.Fatalf("register %s: no access_token in response (user inactive? default_user_active policy may not have committed): %s",
username, w.Body.String())
}
// Extract user_id from profile
w2 := h.request("GET", "/api/v1/profile", token, nil)
@@ -605,9 +613,9 @@ func TestIntegration_AdminListUsers(t *testing.T) {
}
var resp map[string]interface{}
decode(w, &resp)
users, ok := resp["users"].([]interface{})
users, ok := resp["data"].([]interface{})
if !ok {
t.Fatalf("response must have 'users' array, got %T", resp["users"])
t.Fatalf("response must have 'data' array, got %T", resp["data"])
}
if len(users) < 1 {
t.Fatal("should have at least 1 user (admin)")
@@ -906,10 +914,10 @@ func TestIntegration_TeamMemberManagement(t *testing.T) {
}
var usersResp map[string]interface{}
decode(w, &usersResp)
if _, ok := usersResp["users"]; !ok {
t.Fatal("admin/users response MUST have 'users' key (not 'data')")
if _, ok := usersResp["data"]; !ok {
t.Fatal("admin/users response MUST have 'data' key")
}
users := usersResp["users"].([]interface{})
users := usersResp["data"].([]interface{})
if len(users) < 2 {
t.Fatalf("expected at least 2 users (admin + alice), got %d", len(users))
}
@@ -1166,13 +1174,13 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) {
}
var adminResp map[string]interface{}
decode(w, &adminResp)
adminModels := adminResp["models"].([]interface{})
adminModels := adminResp["data"].([]interface{})
if len(adminModels) != 3 {
t.Fatalf("admin should see 3 disabled models, got %d", len(adminModels))
}
// Verify admin response is non-null array (not {"models": null})
if adminResp["models"] == nil {
// Verify admin response is non-null array (not {"data": null})
if adminResp["data"] == nil {
t.Fatal("admin models must be [] not null — causes frontend fallback chain to break")
}
@@ -1863,7 +1871,7 @@ func TestUserJourney_FullMatrix(t *testing.T) {
}
var resp map[string]interface{}
decode(w, &resp)
allModels := resp["models"].([]interface{})
allModels := resp["data"].([]interface{})
// Admin should see only global-scope models (3), not team(2) or BYOK(2)
if len(allModels) != 3 {
t.Fatalf("admin/models should show 3 global entries, got %d", len(allModels))
@@ -2004,12 +2012,8 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
}
var roleResp map[string]interface{}
decode(w, &roleResp)
roleData, _ := roleResp["data"].(map[string]interface{})
if roleData == nil {
roleData = map[string]interface{}{}
}
if len(roleData) != 0 {
t.Fatalf("team roles should be empty initially, got %d", len(roleData))
if len(roleResp) != 0 {
t.Fatalf("team roles should be empty initially, got %d", len(roleResp))
}
// Set team role override
@@ -2028,11 +2032,10 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
roleResp = map[string]interface{}{}
decode(w, &roleResp)
roleData, _ = roleResp["data"].(map[string]interface{})
if roleData == nil || len(roleData) == 0 {
if len(roleResp) == 0 {
t.Fatal("team roles should have utility after update")
}
if _, ok := roleData["utility"]; !ok {
if _, ok := roleResp["utility"]; !ok {
t.Fatal("team roles should have utility after update")
}
@@ -2046,11 +2049,8 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
roleResp = map[string]interface{}{}
decode(w, &roleResp)
roleData, _ = roleResp["data"].(map[string]interface{})
if roleData != nil {
if _, ok := roleData["utility"]; ok {
t.Fatal("team roles should not have utility after delete")
}
if _, ok := roleResp["utility"]; ok {
t.Fatal("team roles should not have utility after delete")
}
}
@@ -3749,7 +3749,7 @@ func TestIntegration_Messages_TreePath(t *testing.T) {
}
var pathEnv map[string]interface{}
decode(w, &pathEnv)
pathResp := pathEnv["messages"].([]interface{})
pathResp := pathEnv["data"].([]interface{})
if len(pathResp) != 3 {
t.Fatalf("expected 3 messages in path, got %d", len(pathResp))
}
@@ -4291,8 +4291,8 @@ func TestAudit_TeamProvidersEnvelope(t *testing.T) {
}
// ── H9: Team Roles Envelope ────────────────
// BUG: ListTeamRoles returns bare map, not wrapped in {"data": ...}.
// Roles endpoints return composite named-key maps (not lists).
// Convention: composite endpoints return object directly, no {"data": ...} wrapper.
func TestAudit_TeamRolesEnvelope(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
@@ -4318,10 +4318,10 @@ func TestAudit_TeamRolesEnvelope(t *testing.T) {
var resp map[string]interface{}
decode(w, &resp)
// Should be wrapped in {"data": {...}} per composite convention
if _, ok := resp["data"]; !ok {
t.Fatalf("H9 BUG: GET /teams/:teamId/roles should return 'data' key (composite convention), "+
"got keys: %v — bare map returned without wrapper", mapKeys(resp))
// Composite endpoint — must NOT have a "data" wrapper
if _, ok := resp["data"]; ok {
t.Fatalf("GET /teams/:teamId/roles should return named keys directly (composite convention), "+
"not wrapped in {\"data\": ...}")
}
}

View File

@@ -197,7 +197,7 @@ func trySetupProvider(h *testHarness, adminToken string, pc liveProviderConfig)
var fallbackCatalogID, fallbackModelID string
var toolCapableCatalogID, toolCapableModelID string
for _, raw := range modelsResp["models"].([]interface{}) {
for _, raw := range modelsResp["data"].([]interface{}) {
m := raw.(map[string]interface{})
if m["visibility"].(string) != "disabled" {
continue
@@ -318,7 +318,7 @@ func setupProviderWithModel(t *testing.T, h *testHarness, adminToken string, pc
var catalogID, modelID string
var fallbackCatalogID, fallbackModelID string
for _, raw := range modelsResp["models"].([]interface{}) {
for _, raw := range modelsResp["data"].([]interface{}) {
m := raw.(map[string]interface{})
if m["visibility"].(string) != "disabled" {
continue
@@ -410,7 +410,7 @@ func TestLive_ProviderFullFlow(t *testing.T) {
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var modelsResp map[string]interface{}
decode(w, &modelsResp)
catalogModels := modelsResp["models"].([]interface{})
catalogModels := modelsResp["data"].([]interface{})
var enableID, enableModelID string
var fallbackID, fallbackModelID string
@@ -518,7 +518,7 @@ func TestLive_FetchModelsCapabilities(t *testing.T) {
var resp map[string]interface{}
decode(w, &resp)
for _, raw := range resp["models"].([]interface{}) {
for _, raw := range resp["data"].([]interface{}) {
m := raw.(map[string]interface{})
caps, ok := m["capabilities"].(map[string]interface{})
if !ok {

View File

@@ -1,17 +1,20 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"time"
"github.com/gin-gonic/gin"
capspkg "chat-switchboard/capabilities"
"chat-switchboard/crypto"
"chat-switchboard/database"
"chat-switchboard/events"
"chat-switchboard/models"
"chat-switchboard/providers"
@@ -153,7 +156,7 @@ func (h *MessageHandler) GetActivePath(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"messages": path})
c.JSON(http.StatusOK, gin.H{"data": path})
}
// ── Create Message (manual) ─────────────────
@@ -218,6 +221,11 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
return
}
// Broadcast via WS to channel participants
if h.hub != nil && msg.Role == "user" {
broadcastUserMessage(c.Request.Context(), h.hub, channelID, msg.ID, msg.Content, userID)
}
resp := messageResponse{
ID: msg.ID,
ChannelID: msg.ChannelID,
@@ -579,7 +587,7 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"messages": path, "active_leaf_id": leafID})
c.JSON(http.StatusOK, gin.H{"data": path, "active_leaf_id": leafID})
}
// ── List Siblings ───────────────────────────
@@ -638,3 +646,157 @@ func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
}
return true
}
// broadcastUserMessage publishes a message.created event to all user
// participants in the channel via WebSocket.
func broadcastUserMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, senderID string) {
// Look up sender display name
var displayName, username string
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT COALESCE(display_name, ''), COALESCE(username, '') FROM users WHERE id = $1
`), senderID).Scan(&displayName, &username)
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
"role": "user",
"content": content,
"user_id": senderID,
"display_name": displayName,
"username": username,
"created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
evt := events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
// Send to all user participants in the channel
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
// Fallback: at least send to the sender
hub.PublishToUser(senderID, evt)
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil {
hub.PublishToUser(pid, evt)
}
}
}
// broadcastAssistantMessage publishes a message.created event for an assistant
// message to all user participants in the channel (except the requesting user,
// who already received the response via SSE).
func broadcastAssistantMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, model, senderUserID, personaID string) {
// Look up persona display name if available
var displayName, avatar string
if personaID != "" {
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT COALESCE(name, ''), COALESCE(avatar, '') FROM personas WHERE id = $1
`), personaID).Scan(&displayName, &avatar)
}
if displayName == "" {
displayName = model
}
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
"role": "assistant",
"content": content,
"model": model,
"participant_type": "persona",
"participant_id": personaID,
"display_name": displayName,
"avatar": avatar,
"created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
evt := events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
// Send to all user participants except the sender (who got SSE)
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil && pid != senderUserID {
hub.PublishToUser(pid, evt)
}
}
}
// ── Delete Message ──────────────────────────────────────────
// DeleteMessage soft-deletes a message.
// DELETE /channels/:id/messages/:msgId
func (h *MessageHandler) DeleteMessage(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
msgID := c.Param("msgId")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Verify message belongs to this channel and is not already deleted
var exists bool
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL)
`), msgID, channelID).Scan(&exists)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
if err := h.stores.Messages.Delete(c.Request.Context(), msgID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete message"})
return
}
// Broadcast to channel participants (exclude sender — they already removed it)
broadcastMessageDeleted(c.Request.Context(), h.hub, channelID, msgID, userID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// broadcastMessageDeleted publishes a message.deleted event to all user
// participants in the channel except the sender.
func broadcastMessageDeleted(ctx context.Context, hub *events.Hub, channelID, msgID, senderID string) {
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
})
evt := events.Event{
Label: "message.deleted",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil && pid != senderID {
hub.PublishToUser(pid, evt)
}
}
}

View File

@@ -458,7 +458,7 @@ func (h *NoteHandler) ListFolders(c *gin.Context) {
folders = append(folders, folderInfo{Path: f.Path, Count: f.Count})
}
c.JSON(http.StatusOK, gin.H{"folders": folders})
c.JSON(http.StatusOK, gin.H{"data": folders})
}
// ── Helpers ─────────────────────────────────

View File

@@ -101,7 +101,7 @@ func (h *RegistryHandler) BrowseRegistry(c *gin.Context) {
url := h.getRegistryURL(c)
if url == "" {
c.JSON(http.StatusOK, gin.H{
"packages": []RegistryEntry{},
"data":[]RegistryEntry{},
"registry_url": "",
})
return
@@ -113,7 +113,7 @@ func (h *RegistryHandler) BrowseRegistry(c *gin.Context) {
data := h.cacheData
h.cacheMu.RUnlock()
c.JSON(http.StatusOK, gin.H{
"packages": data.Packages,
"data":data.Packages,
"registry_url": url,
})
return
@@ -156,7 +156,7 @@ func (h *RegistryHandler) BrowseRegistry(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"packages": entries,
"data":entries,
"registry_url": url,
})
}

View File

@@ -560,7 +560,7 @@ func (h *PackageHandler) UpdatePackageSettings(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"values": json.RawMessage(settingsJSON)})
c.JSON(http.StatusOK, gin.H{"data": json.RawMessage(settingsJSON)})
}
// validateSettingType checks if a value matches the expected setting type.

View File

@@ -46,7 +46,7 @@ func (h *ParticipantHandler) List(c *gin.Context) {
if participants == nil {
participants = []models.ChannelParticipant{}
}
c.JSON(http.StatusOK, gin.H{"participants": participants})
c.JSON(http.StatusOK, gin.H{"data": participants})
}
// ── Add ──────────────────────────────────────
@@ -167,7 +167,7 @@ func (h *ParticipantHandler) Add(c *gin.Context) {
// Return updated participant list
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusCreated, gin.H{"participants": participants})
c.JSON(http.StatusCreated, gin.H{"data": participants})
}
// ── Update Role ─────────────────────────────
@@ -220,7 +220,7 @@ func (h *ParticipantHandler) Update(c *gin.Context) {
}
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"participants": participants})
c.JSON(http.StatusOK, gin.H{"data": participants})
}
// ── Remove ──────────────────────────────────
@@ -289,7 +289,7 @@ func (h *ParticipantHandler) Remove(c *gin.Context) {
}
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"participants": participants})
c.JSON(http.StatusOK, gin.H{"data": participants})
}
// ── Helpers ──────────────────────────────────

View File

@@ -80,5 +80,5 @@ func (h *PresenceHandler) SearchUsers(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
c.JSON(http.StatusOK, gin.H{"users": results})
c.JSON(http.StatusOK, gin.H{"data": results})
}

View File

@@ -0,0 +1,117 @@
package handlers
// profile_bootstrap.go — Single-call boot payload for the SDK.
//
// GET /api/v1/profile/bootstrap
//
// Collapses what previously required 3-4 sequential requests
// (profile, permissions, teams/mine, settings) into one call.
// The SDK calls this at startup and on token refresh.
//
// v0.37.15
import (
"net/http"
"sort"
"github.com/gin-gonic/gin"
"chat-switchboard/auth"
"chat-switchboard/store"
)
// ProfileBootstrapHandler serves the combined boot payload.
type ProfileBootstrapHandler struct {
stores store.Stores
}
func NewProfileBootstrapHandler(s store.Stores) *ProfileBootstrapHandler {
return &ProfileBootstrapHandler{stores: s}
}
// GetBootstrap returns everything the shell needs at startup.
// GET /api/v1/profile/bootstrap
func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) {
userID := getUserID(c)
role, _ := c.Get("role")
ctx := c.Request.Context()
// ── User profile ────────────────────────
user, err := h.stores.Users.GetByID(ctx, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
userPayload := gin.H{
"id": user.ID,
"username": user.Username,
"display_name": user.DisplayName,
"email": user.Email,
"role": user.Role,
}
if user.AvatarURL != "" {
userPayload["avatar"] = user.AvatarURL
}
// ── Permissions ─────────────────────────
var permList []string
if role == "admin" {
permList = make([]string, len(auth.AllPermissions))
copy(permList, auth.AllPermissions)
} else {
perms, err := auth.ResolvePermissions(ctx, h.stores, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"})
return
}
permList = make([]string, 0, len(perms))
for p := range perms {
permList = append(permList, p)
}
}
sort.Strings(permList)
// ── Groups ──────────────────────────────
groupIDs, _ := h.stores.Groups.GetUserGroupIDs(ctx, userID)
if groupIDs == nil {
groupIDs = []string{}
}
groupIDs = append(groupIDs, auth.EveryoneGroupID)
// ── Teams ───────────────────────────────
teams, _ := h.stores.Teams.ListForUser(ctx, userID)
teamData := make([]gin.H, 0, len(teams))
for _, t := range teams {
teamData = append(teamData, gin.H{
"id": t.ID,
"name": t.Name,
"my_role": t.MyRole,
})
}
// ── Policies ────────────────────────────
policies := make(map[string]bool)
if ps := h.stores.Policies; ps != nil {
policies["allow_user_byok"], _ = ps.GetBool(ctx, "allow_user_byok")
policies["allow_user_personas"], _ = ps.GetBool(ctx, "allow_user_personas")
policies["allow_raw_model_access"], _ = ps.GetBool(ctx, "allow_raw_model_access")
policies["kb_direct_access"], _ = ps.GetBool(ctx, "kb_direct_access")
}
// ── Settings ────────────────────────────
settings := make(map[string]interface{})
if user.Settings != nil {
settings = user.Settings
}
// ── Response ────────────────────────────
c.JSON(http.StatusOK, gin.H{
"user": userPayload,
"permissions": permList,
"groups": groupIDs,
"teams": teamData,
"policies": policies,
"settings": settings,
})
}

View File

@@ -161,7 +161,7 @@ func (h *RolesHandler) ListTeamRoles(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"data": roleOverrides})
c.JSON(http.StatusOK, roleOverrides)
}
// UpdateTeamRole sets a team role override.

View File

@@ -326,7 +326,7 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
out = append(out, modelInfo{ID: m.ID, Type: m.Type, Capabilities: caps})
}
c.JSON(http.StatusOK, gin.H{"models": out, "provider": cfg.Name})
c.JSON(http.StatusOK, gin.H{"data": out, "provider": cfg.Name})
}
// parseJSONBConfig parses a JSONB text string into a map.

View File

@@ -428,7 +428,7 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"models": result})
c.JSON(http.StatusOK, gin.H{"data": result})
}
// ── Helpers ─────────────────────────────────