Changeset 0.37.14 (#226)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">×</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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
124
server/retention/scanner.go
Normal 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
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, `
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user