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

@@ -138,7 +138,8 @@ INSERT INTO global_settings (key, value) VALUES
"utility": { "primary": null, "fallback": null },
"embedding": { "primary": null, "fallback": null },
"generation": { "primary": null, "fallback": null }
}'::jsonb)
}'::jsonb),
('retention_ttl_days', '{"value": 0}'::jsonb)
ON CONFLICT (key) DO NOTHING;

View File

@@ -81,6 +81,9 @@ CREATE TABLE IF NOT EXISTS channels (
project_id UUID,
workspace_id UUID,
-- Retention (v0.37.14)
purge_after TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
@@ -95,6 +98,7 @@ CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id) WHERE pr
CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id) WHERE workspace_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_channels_workflow ON channels(workflow_id) WHERE workflow_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_channels_workflow_status ON channels(workflow_status) WHERE workflow_status = 'active';
CREATE INDEX IF NOT EXISTS idx_channels_purge_after ON channels(purge_after) WHERE purge_after IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_channels_workflow_active ON channels(workflow_id, workflow_status) WHERE workflow_id IS NOT NULL AND workflow_status = 'active';
DROP TRIGGER IF EXISTS channels_updated_at ON channels;

View File

@@ -78,7 +78,8 @@ INSERT OR IGNORE INTO global_settings (key, value) VALUES
('registration', '{"enabled": true}'),
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'),
('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'),
('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}');
('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}'),
('retention_ttl_days', '{"value": 0}');
CREATE TABLE IF NOT EXISTS user_presence (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,

View File

@@ -46,6 +46,7 @@ CREATE TABLE IF NOT EXISTS channels (
kb_auto_inject INTEGER NOT NULL DEFAULT 0,
project_id TEXT,
workspace_id TEXT,
purge_after TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);

View File

@@ -53,6 +53,7 @@ var routeTable = map[string]Direction{
"user.mentioned": DirToClient, // v0.23.2: targeted @mention notification
"typing.user": DirToClient, // v0.23.2: human typing in DM/channel
"message.created": DirToClient, // v0.23.2: chained/DM message delivery
"message.deleted": DirToClient, // v0.37.14: soft-delete broadcast
// System
"system.notify": DirToClient,

View File

@@ -251,7 +251,7 @@ func (c *Conn) subscribeToBus() {
}
// Don't echo typing events back to the sender
if e.Label == "chat.typing" || e.ConnID == c.id {
if strings.HasPrefix(e.Label, "chat.typing.") || strings.HasPrefix(e.Label, "channel.typing.") {
if e.SenderID == c.userID && e.ConnID == c.id {
return
}

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 ─────────────────────────────────

View File

@@ -35,6 +35,7 @@ import (
"chat-switchboard/notifications"
"chat-switchboard/pages"
"chat-switchboard/providers"
"chat-switchboard/retention"
"chat-switchboard/roles"
"chat-switchboard/routing"
"chat-switchboard/scheduler"
@@ -234,6 +235,11 @@ func main() {
}()
}
// v0.37.14: Channel retention scanner — purges archived channels past their TTL
retScanner := retention.NewScanner(stores, objStore, retention.ScannerConfig{})
retScanner.Start()
defer retScanner.Stop()
// v0.27.2: Task scheduler startup deferred to after hub/notification init — see below.
// Bootstrap admin from env (K8s secret) — upserts on every restart
@@ -751,6 +757,7 @@ func main() {
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
protected.DELETE("/channels/:id/messages/:msgId", msgs.DeleteMessage)
// Chat Completions
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder)
@@ -824,6 +831,10 @@ func main() {
permH := handlers.NewProfilePermissionsHandler(stores)
protected.GET("/profile/permissions", permH.GetMyPermissions)
// Boot payload (v0.37.15) — single-call SDK bootstrap
bootH := handlers.NewProfileBootstrapHandler(stores)
protected.GET("/profile/bootstrap", bootH.GetBootstrap)
// Usage (personal)
usage := handlers.NewUsageHandler(stores)
protected.GET("/usage", usage.PersonalUsage)

View File

@@ -328,8 +328,9 @@ type Channel struct {
Model string `json:"model,omitempty" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
IsArchived bool `json:"is_archived" db:"is_archived"`
IsPinned bool `json:"is_pinned" db:"is_pinned"`
IsArchived bool `json:"is_archived" db:"is_archived"`
IsPinned bool `json:"is_pinned" db:"is_pinned"`
PurgeAfter *time.Time `json:"purge_after,omitempty" db:"purge_after"`
FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
ProjectID *string `json:"project_id,omitempty" db:"project_id"`

View File

@@ -149,7 +149,7 @@ func (e *Engine) adminLoader(c *gin.Context, s store.Stores) (any, error) {
ctx := context.Background()
section := c.Param("section")
if section == "" {
section = "overview"
section = "users"
}
data := &AdminPageData{

View File

@@ -68,9 +68,24 @@ type BannerConfig struct {
Visible bool `json:"visible"`
}
// MessageConfig holds dismissible message bar settings.
type MessageConfig struct {
Text string `json:"text"`
Variant string `json:"variant"` // info, warn, error, success
Visible bool `json:"visible"`
}
// FooterConfig holds optional footer bar settings.
type FooterConfig struct {
Text string `json:"text"`
Visible bool `json:"visible"`
}
// PageData is passed to every template render.
type PageData struct {
Banner BannerConfig
Message MessageConfig
Footer FooterConfig
Surface string // active surface ID
Section string // sub-section (for admin pages)
CSPNonce string
@@ -300,6 +315,8 @@ func (e *Engine) Render(c *gin.Context, name string, data PageData) {
data.Environment = e.cfg.Environment
data.CSPNonce = generateNonce()
data.Banner = e.loadBanner()
data.Message = e.loadMessage()
data.Footer = e.loadFooter()
// v0.22.7: Default theme if not set
if data.Theme == "" {
@@ -336,7 +353,7 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
section := c.Param("section")
if section == "" && surfaceID == "admin" {
section = "overview"
section = "users"
}
if section == "" && surfaceID == "settings" {
section = "general"
@@ -837,6 +854,50 @@ func (e *Engine) loadBanner() BannerConfig {
return b
}
// loadMessage reads dismissible message bar config from global settings.
func (e *Engine) loadMessage() MessageConfig {
if e.stores.GlobalConfig == nil {
return MessageConfig{}
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "message")
if err != nil || raw == nil {
return MessageConfig{}
}
m := MessageConfig{}
if v, ok := raw["enabled"].(bool); ok {
m.Visible = v
}
if v, ok := raw["text"].(string); ok {
m.Text = v
}
if v, ok := raw["variant"].(string); ok {
m.Variant = v
}
if m.Variant == "" {
m.Variant = "info"
}
return m
}
// loadFooter reads optional footer bar config from global settings.
func (e *Engine) loadFooter() FooterConfig {
if e.stores.GlobalConfig == nil {
return FooterConfig{}
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "footer")
if err != nil || raw == nil {
return FooterConfig{}
}
f := FooterConfig{}
if v, ok := raw["enabled"].(bool); ok {
f.Visible = v
}
if v, ok := raw["text"].(string); ok {
f.Text = v
}
return f
}
// loadBranding reads instance branding from global settings.
func (e *Engine) loadBranding() (name, logoURL, tagline string) {
name = "Chat Switchboard" // default

View File

@@ -3,7 +3,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
<title>{{block "title" .}}Chat Switchboard{{end}}</title>
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
@@ -24,6 +24,7 @@
<link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/extension-surface.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-shell.css?v={{.Version}}">
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
{{if eq .Surface "notes"}}{{template "css-notes" .}}{{end}}
{{/* v0.27.0: Extension surface CSS — loaded from /surfaces/{id}/css/main.css */}}
@@ -51,6 +52,23 @@
document.documentElement.setAttribute('data-theme', resolved);
var meta = document.getElementById('metaThemeColor');
if (meta) meta.content = resolved === 'light' ? '#f7f7fa' : '#0e0e10';
var fav = document.querySelector('link[rel="icon"][type="image/svg+xml"]');
if (fav) fav.href = fav.href.replace(/favicon(-light)?\.svg/, resolved === 'light' ? 'favicon-light.svg' : 'favicon.svg');
} catch(e) {}
})();
// Early appearance — apply scale + msg font from localStorage on all surfaces.
// Scale uses transform on .surface-inner so shell stays fixed, content fills viewport.
(function() {
try {
var p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
if (p.scale && p.scale !== 100) {
var s = p.scale / 100;
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('surfaceInner');
if (el) { el.style.transform = 'scale('+s+')'; el.style.transformOrigin = 'top left'; el.style.width = (100/s)+'%'; el.style.height = (100/s)+'%'; }
});
}
if (p.msgFont && p.msgFont !== 14) document.documentElement.style.setProperty('--msg-font', p.msgFont + 'px');
} catch(e) {}
})();
</script>
@@ -62,6 +80,15 @@
</div>
{{end}}
{{if .Message.Visible}}
<div class="sw-shell__announcement" id="shellMessage">
<div class="sw-shell__announcement-inner sw-shell__announcement--{{.Message.Variant}}">
<span class="sw-shell__announcement-text">{{.Message.Text}}</span>
<button class="sw-shell__banner-close" onclick="this.closest('.sw-shell__announcement').remove()" aria-label="Dismiss">&times;</button>
</div>
</div>
{{end}}
<div class="surface" id="surface">
<div class="surface-inner" id="surfaceInner">
{{if eq .Surface "chat"}}{{template "surface-chat" .}}
@@ -75,6 +102,10 @@
</div>
</div>
{{if .Footer.Visible}}
<div class="sw-shell__footer">{{.Footer.Text}}</div>
{{end}}
{{if .Banner.Visible}}
<div class="banner banner-bottom active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;">
{{.Banner.Text}}
@@ -92,32 +123,9 @@
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
</script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/sb.js?v={{.Version}}"></script>
{{/* v0.37.13 Scorched Earth III: app-state.js, ui-primitives.js,
user-menu.js, file-tree.js, code-editor.js removed.
Earlier: api.js, ui-core.js, pages.js (v0.37.10),
ui-format.js, virtual-scroll.js, model-selector.js, drag-resize.js (v0.37.12),
chat-pane.js, pane-container.js (v0.37.10). */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/events.js?v={{.Version}}"></script>
{{/* v0.28.5: SDK — composition layer over globals. */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/switchboard-sdk.js?v={{.Version}}"></script>
{{/* ── Universal init: SDK boot for ALL surfaces ── */}}
<script type="module" nonce="{{.CSPNonce}}">
// v0.28.5: SDK handles theme + appearance. Idempotent.
Switchboard.init();
// Universal logout — available on every surface.
// Preact surfaces override with richer versions.
function handleLogout() {
if (!confirm('Sign out?')) return;
if (typeof Events !== 'undefined') { Events.disconnect(); Events.clear(); }
if (typeof API !== 'undefined' && API.logout) API.logout();
location.reload();
}
// Register fallback — app.js re-registers with a richer version.
sb.register('handleLogout', handleLogout);
</script>
{{/* v0.37.14 Scorched Earth IV: sb.js, events.js, switchboard-sdk.js,
workflow-surfaces.js removed. All surfaces use Preact SDK boot().
Survivors: debug.js, repl.js (standalone, no old-layer deps). */}}
{{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}}
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}
@@ -136,13 +144,13 @@
<div class="modal modal-wide">
<div class="modal-header">
<h2>Debug Log</h2>
<button class="modal-close" onclick="sb.call('closeModal','debugModal')"></button>
<button class="modal-close" onclick="closeModal('debugModal')"></button>
</div>
<div class="modal-tabs">
<button class="debug-tab active" data-tab="console" onclick="sb.call('switchDebugTab','console')">Console <span id="debugConsoleCount" class="badge">0</span></button>
<button class="debug-tab" data-tab="network" onclick="sb.call('switchDebugTab','network')">Network <span id="debugNetworkCount" class="badge">0</span></button>
<button class="debug-tab" data-tab="state" onclick="sb.call('switchDebugTab','state')">State</button>
<button class="debug-tab" data-tab="repl" onclick="sb.call('switchDebugTab','repl')">REPL</button>
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console <span id="debugConsoleCount" class="badge">0</span></button>
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network <span id="debugNetworkCount" class="badge">0</span></button>
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
<button class="debug-tab" data-tab="repl" onclick="switchDebugTab('repl')">REPL</button>
</div>
<div class="modal-body" style="padding:0;overflow:hidden;display:flex;flex-direction:column;min-height:0;">
<div id="debugConsoleTab" class="debug-tab-content" style="display:flex;flex-direction:column;flex:1;min-height:0;">
@@ -167,20 +175,19 @@
</div>
<div class="modal-footer" style="display:flex;justify-content:space-between;align-items:center;">
<div style="display:flex;gap:8px;">
<button class="btn-danger btn-small" onclick="sb.call('runDebugDiagnostics')">Diagnostics</button>
<button class="btn-danger btn-small" onclick="sb.call('purgeCache')">Purge Cache</button>
<button class="btn-danger btn-small" onclick="runDebugDiagnostics()">Diagnostics</button>
<button class="btn-danger btn-small" onclick="purgeCache()">Purge Cache</button>
</div>
<div style="display:flex;gap:8px;">
<button class="btn-secondary btn-small" onclick="sb.call('clearDebugLog')">Clear</button>
<button class="btn-secondary btn-small" onclick="sb.call('copyDebugLog')">Copy</button>
<button class="btn-secondary btn-small" onclick="sb.call('exportDebugLog')">Export</button>
<button class="btn-secondary btn-small" onclick="clearDebugLog()">Clear</button>
<button class="btn-secondary btn-small" onclick="copyDebugLog()">Copy</button>
<button class="btn-secondary btn-small" onclick="exportDebugLog()">Export</button>
</div>
</div>
</div>
</div>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/debug.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/repl.js?v={{.Version}}"></script>
<div id="toastContainer" class="toast-container"></div>
</body>
</html>
{{end}}

View File

@@ -47,6 +47,7 @@ window.addEventListener('unhandledrejection', function(e) {
{{define "scripts-chat"}}
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/purify.min.js"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/codemirror/codemirror.bundle.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}">
// v0.37.10: Preact boot — same pattern as settings/admin/team-admin surfaces.
// Vendor modules: no ?v= query — hooks.module.js does a bare

View File

@@ -13,9 +13,6 @@
{{define "surface-extension"}}
<div id="extension-surface" class="extension-surface"
data-surface-id="{{.Surface}}">
{{/* User menu — standard empty prefix, hydrated by base.html universal init */}}
{{template "user-menu" dict "ID" ""}}
<div id="extension-mount" class="extension-mount"></div>
</div>
{{end}}

View File

@@ -47,6 +47,7 @@ window.addEventListener('unhandledrejection', function(e) {
{{define "scripts-notes"}}
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/purify.min.js"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/codemirror/codemirror.bundle.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}">
// v0.37.11: Preact boot — same pattern as chat surface.
const { h, render } = await import('{{$.BasePath}}/js/sw/vendor/preact.module.js');

View File

@@ -593,32 +593,12 @@
});
}
// ── Custom surface (v0.30.2) ──────────
// ── Custom surface (v0.37.14: legacy registry removed) ──────────
if (SURFACE_PKG_ID) {
(async function() {
var mount = document.getElementById('customSurfaceMount');
if (!mount) return;
mount.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading surface\u2026</div>';
try {
// Load workflow-surfaces registry + dependencies
await loadScript(BASE + '/js/ui-primitives.js');
await loadScript(BASE + '/js/workflow-surfaces.js');
// Load the package's surface JS (registers via WorkflowSurfaces.register())
await loadScript(BASE + '/surfaces/' + SURFACE_PKG_ID + '/js/main.js');
// Mount the custom surface
var ctx = { channelId: CHAN_ID, sessionId: SESSION_ID, basePath: BASE,
stageMode: STAGE_MODE, formTemplate: FORM_TPL,
totalStages: TOTAL_STAGES, currentStage: CURRENT_STAGE };
mount.innerHTML = '';
if (typeof WorkflowSurfaces !== 'undefined') {
WorkflowSurfaces.mount(mount, SURFACE_PKG_ID, ctx);
} else {
mount.innerHTML = '<div style="padding:24px;color:var(--danger)">Failed to load surface registry.</div>';
}
} catch(e) {
mount.innerHTML = '<div style="padding:24px;color:var(--danger)">Failed to load surface: ' + escHtml(e.message) + '</div>';
}
})();
var mount = document.getElementById('customSurfaceMount');
if (mount) {
mount.innerHTML = '<div style="padding:24px;color:var(--danger)">Custom workflow surfaces require an updated package format. The legacy surface registry has been removed.</div>';
}
}
function loadScript(src) {

124
server/retention/scanner.go Normal file
View File

@@ -0,0 +1,124 @@
package retention
import (
"context"
"fmt"
"log"
"sync"
"time"
"chat-switchboard/storage"
"chat-switchboard/store"
)
const DefaultInterval = 1 * time.Hour
// ScannerConfig holds startup configuration.
type ScannerConfig struct {
Interval time.Duration
}
// Scanner periodically purges channels whose purge_after timestamp has passed.
// Channels with global/team provider scopes are archived with a TTL by
// DeleteChannel; this scanner performs the deferred hard-delete.
type Scanner struct {
stores store.Stores
objStore storage.ObjectStore
wg sync.WaitGroup
stopCh chan struct{}
interval time.Duration
}
// NewScanner creates a retention scanner. Call Start() to begin.
func NewScanner(stores store.Stores, objStore storage.ObjectStore, cfg ScannerConfig) *Scanner {
interval := cfg.Interval
if interval <= 0 {
interval = DefaultInterval
}
return &Scanner{
stores: stores,
objStore: objStore,
stopCh: make(chan struct{}),
interval: interval,
}
}
// Start begins the scan loop in a background goroutine.
func (sc *Scanner) Start() {
sc.wg.Add(1)
go func() {
defer sc.wg.Done()
sc.loop()
}()
log.Printf("[retention] scanner started (interval=%s)", sc.interval)
}
// Stop signals the scanner to stop and waits for in-flight work to drain.
func (sc *Scanner) Stop() {
close(sc.stopCh)
sc.wg.Wait()
log.Printf("[retention] scanner stopped")
}
func (sc *Scanner) loop() {
ticker := time.NewTicker(sc.interval)
defer ticker.Stop()
for {
select {
case <-sc.stopCh:
return
case <-ticker.C:
sc.tick()
}
}
}
func (sc *Scanner) tick() {
ctx := context.Background()
// If TTL is 0 the feature is disabled — nothing to purge
ttl := sc.retentionTTL(ctx)
if ttl <= 0 {
return
}
ids, err := sc.stores.Channels.ListPurgeable(ctx)
if err != nil {
log.Printf("[retention] ListPurgeable error: %v", err)
return
}
if len(ids) == 0 {
return
}
log.Printf("[retention] purging %d channel(s)", len(ids))
for _, id := range ids {
// Clean up storage files first
if sc.objStore != nil {
prefix := fmt.Sprintf("files/%s", id)
if err := sc.objStore.DeletePrefix(ctx, prefix); err != nil {
log.Printf("[retention] storage cleanup for %s failed: %v", id, err)
}
}
// Hard delete (Purge verifies is_archived)
if err := sc.stores.Channels.Purge(ctx, id); err != nil {
log.Printf("[retention] purge %s failed: %v", id, err)
}
}
}
func (sc *Scanner) retentionTTL(ctx context.Context) int {
cfg, err := sc.stores.GlobalConfig.Get(ctx, "retention_ttl_days")
if err != nil {
return 0
}
if v, ok := cfg["value"].(float64); ok {
return int(v)
}
return 0
}

View File

@@ -738,20 +738,34 @@ paths:
delete:
tags:
- Channels
summary: Archive a channel
summary: Delete or leave a channel
description: |
Owner: deletes (or archives for retention when TTL > 0).
Non-owner participant: leaves the channel.
When retention_ttl_days > 0, all channels are archived and purged after TTL.
Only Personal (BYOK) provider channels are exempt and always immediately deleted.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
responses:
'200':
description: Archived
description: Deleted, archived for retention, or left channel
content:
application/json:
schema:
$ref: '#/components/schemas/MessageResponse'
'400':
$ref: '#/components/responses/BadRequest'
type: object
properties:
message:
type: string
enum:
- channel deleted
- channel archived for retention
- left channel
purge_after:
type: string
format: date-time
description: Only present when archived for retention
'401':
$ref: '#/components/responses/Unauthorized'
'404':

View File

@@ -481,6 +481,12 @@ type ChannelStore interface {
// Returns rows affected (0 = not found or not owner).
DeleteByOwner(ctx context.Context, channelID, userID string) (int64, error)
// ArchiveForRetention archives a channel and sets a purge_after timestamp.
ArchiveForRetention(ctx context.Context, channelID string, purgeAfter time.Time) error
// ListPurgeable returns IDs of channels whose purge_after has passed.
ListPurgeable(ctx context.Context) ([]string, error)
// MarkRead updates last_read_at and last_read_message_id for a user.
MarkRead(ctx context.Context, channelID, userID string) error
@@ -1165,7 +1171,7 @@ type PersonaGroupStore interface {
type FolderStore interface {
List(ctx context.Context, userID string) ([]models.Folder, error)
Create(ctx context.Context, f *models.Folder) error
Update(ctx context.Context, folderID, userID string, name string, sortOrder *int) (int64, error)
Update(ctx context.Context, folderID, userID string, name string, sortOrder *int, parentID **string) (int64, error)
Delete(ctx context.Context, folderID, userID string) (int64, error)
// UnassignChannels removes folder_id from all channels in this folder.
UnassignChannels(ctx context.Context, folderID, userID string) error

View File

@@ -339,9 +339,13 @@ func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelPart
func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, participant_type, participant_id, role,
display_name, avatar_url, joined_at
FROM channel_participants WHERE channel_id = $1 ORDER BY joined_at`, channelID)
SELECT cp.id, cp.channel_id, cp.participant_type, cp.participant_id, cp.role,
COALESCE(NULLIF(cp.display_name,''), NULLIF(u.display_name,''), u.username) AS display_name,
COALESCE(NULLIF(cp.avatar_url,''), u.avatar_url) AS avatar_url,
cp.joined_at
FROM channel_participants cp
LEFT JOIN users u ON cp.participant_type = 'user' AND cp.participant_id = u.id
WHERE cp.channel_id = $1 ORDER BY cp.joined_at`, channelID)
if err != nil {
return nil, err
}
@@ -515,6 +519,32 @@ func (s *ChannelStore) DeleteByOwner(ctx context.Context, channelID, userID stri
return result.RowsAffected()
}
func (s *ChannelStore) ArchiveForRetention(ctx context.Context, channelID string, purgeAfter time.Time) error {
_, err := DB.ExecContext(ctx, `
UPDATE channels SET is_archived = true, purge_after = $2, updated_at = NOW()
WHERE id = $1
`, channelID, purgeAfter)
return err
}
func (s *ChannelStore) ListPurgeable(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id FROM channels WHERE purge_after IS NOT NULL AND purge_after <= NOW()
`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
ids = append(ids, id)
}
}
return ids, rows.Err()
}
func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error {
// Update last_read_at
_, err := DB.ExecContext(ctx, `

View File

@@ -39,14 +39,27 @@ func (s *FolderStore) List(ctx context.Context, userID string) ([]models.Folder,
func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error {
return DB.QueryRowContext(ctx, `
INSERT INTO folders (user_id, name, sort_order)
VALUES ($1, $2, $3)
INSERT INTO folders (user_id, name, parent_id, sort_order)
VALUES ($1, $2, $3, $4)
RETURNING id, name, parent_id, sort_order, created_at, updated_at
`, f.UserID, f.Name, f.SortOrder).Scan(
`, f.UserID, f.Name, f.ParentID, f.SortOrder).Scan(
&f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt)
}
func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int) (int64, error) {
func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int, parentID **string) (int64, error) {
if parentID != nil {
res, err := DB.ExecContext(ctx, `
UPDATE folders
SET name = COALESCE(NULLIF($3, ''), name),
sort_order = COALESCE($4, sort_order),
parent_id = $5
WHERE id = $1 AND user_id = $2
`, folderID, userID, name, sortOrder, *parentID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
res, err := DB.ExecContext(ctx, `
UPDATE folders
SET name = COALESCE(NULLIF($3, ''), name),

View File

@@ -256,7 +256,7 @@ func (s *MessageStore) ListWithSenderInfo(ctx context.Context, channelID string,
rows, err := DB.QueryContext(ctx, `
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id,
m.sibling_index, m.participant_type, m.participant_id,
CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username)
CASE WHEN m.participant_type = 'user' THEN COALESCE(NULLIF(u.display_name, ''), u.username)
WHEN m.participant_type = 'persona' THEN p.name
ELSE NULL END AS sender_name,
CASE WHEN m.participant_type = 'user' THEN u.avatar_url

View File

@@ -338,9 +338,13 @@ func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelPart
func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, participant_type, participant_id, role,
display_name, avatar_url, joined_at
FROM channel_participants WHERE channel_id = ? ORDER BY joined_at`, channelID)
SELECT cp.id, cp.channel_id, cp.participant_type, cp.participant_id, cp.role,
COALESCE(NULLIF(cp.display_name,''), NULLIF(u.display_name,''), u.username) AS display_name,
COALESCE(NULLIF(cp.avatar_url,''), u.avatar_url) AS avatar_url,
cp.joined_at
FROM channel_participants cp
LEFT JOIN users u ON cp.participant_type = 'user' AND cp.participant_id = u.id
WHERE cp.channel_id = ? ORDER BY cp.joined_at`, channelID)
if err != nil {
return nil, err
}
@@ -518,6 +522,32 @@ func (s *ChannelStore) DeleteByOwner(ctx context.Context, channelID, userID stri
return result.RowsAffected()
}
func (s *ChannelStore) ArchiveForRetention(ctx context.Context, channelID string, purgeAfter time.Time) error {
_, err := DB.ExecContext(ctx, `
UPDATE channels SET is_archived = 1, purge_after = ?, updated_at = datetime('now')
WHERE id = ?
`, purgeAfter.UTC().Format("2006-01-02T15:04:05Z"), channelID)
return err
}
func (s *ChannelStore) ListPurgeable(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id FROM channels WHERE purge_after IS NOT NULL AND purge_after <= datetime('now')
`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
ids = append(ids, id)
}
}
return ids, rows.Err()
}
func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error {
_, err := DB.ExecContext(ctx, `
UPDATE channel_participants

View File

@@ -41,9 +41,9 @@ func (s *FolderStore) List(ctx context.Context, userID string) ([]models.Folder,
func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error {
f.ID = store.NewID()
_, err := DB.ExecContext(ctx, `
INSERT INTO folders (id, user_id, name, sort_order)
VALUES (?, ?, ?, ?)
`, f.ID, f.UserID, f.Name, f.SortOrder)
INSERT INTO folders (id, user_id, name, parent_id, sort_order)
VALUES (?, ?, ?, ?, ?)
`, f.ID, f.UserID, f.Name, f.ParentID, f.SortOrder)
if err != nil {
return err
}
@@ -53,7 +53,20 @@ func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error {
`, f.ID).Scan(&f.Name, &f.ParentID, &f.SortOrder, st(&f.CreatedAt), st(&f.UpdatedAt))
}
func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int) (int64, error) {
func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int, parentID **string) (int64, error) {
if parentID != nil {
res, err := DB.ExecContext(ctx, `
UPDATE folders
SET name = COALESCE(NULLIF(?, ''), name),
sort_order = COALESCE(?, sort_order),
parent_id = ?
WHERE id = ? AND user_id = ?
`, name, sortOrder, *parentID, folderID, userID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
res, err := DB.ExecContext(ctx, `
UPDATE folders
SET name = COALESCE(NULLIF(?, ''), name),

View File

@@ -260,7 +260,7 @@ func (s *MessageStore) ListWithSenderInfo(ctx context.Context, channelID string,
rows, err := DB.QueryContext(ctx, `
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id,
m.sibling_index, m.participant_type, m.participant_id,
CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username)
CASE WHEN m.participant_type = 'user' THEN COALESCE(NULLIF(u.display_name, ''), u.username)
WHEN m.participant_type = 'persona' THEN p.name
ELSE NULL END AS sender_name,
CASE WHEN m.participant_type = 'user' THEN u.avatar_url

View File

@@ -1,3 +1,25 @@
package main
const Version = "0.37.13"
import (
"os"
"strings"
)
// Version is the application version.
// In Docker builds, injected via ldflags from the VERSION file.
// In local dev, read from ../VERSION at startup.
var Version = "dev"
func init() {
if Version == "dev" {
if b, err := os.ReadFile("../VERSION"); err == nil {
if v := strings.TrimSpace(string(b)); v != "" {
Version = v
}
} else if b, err := os.ReadFile("VERSION"); err == nil {
if v := strings.TrimSpace(string(b)); v != "" {
Version = v
}
}
}
}