Changeset 0.37.14 (#226)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\": ...}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ──────────────────────────────────
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
117
server/handlers/profile_bootstrap.go
Normal file
117
server/handlers/profile_bootstrap.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ─────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user