Changeset 0.29.0 (#195)

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

View File

@@ -126,10 +126,7 @@ func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
return
}
if user.Role == models.UserRoleAdmin {
var adminCount int
database.DB.QueryRowContext(c.Request.Context(),
database.Q(`SELECT COUNT(*) FROM users WHERE role = $1`),
models.UserRoleAdmin).Scan(&adminCount)
adminCount, _ := h.stores.Users.CountByRole(c.Request.Context(), models.UserRoleAdmin)
if adminCount <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "cannot demote the last admin"})
return
@@ -203,7 +200,7 @@ func (h *AdminHandler) destroyVault(c *gin.Context, userID string) {
return
}
deleted := DestroyVaultDB(c.Request.Context(), userID)
deleted := DestroyVaultDB(c.Request.Context(), h.stores, userID)
// Evict from session cache (if user is currently logged in)
h.uekCache.Evict(userID)
@@ -236,7 +233,7 @@ func (h *AdminHandler) ResetVault(c *gin.Context) {
return
}
deleted := DestroyVaultDB(c.Request.Context(), userID)
deleted := DestroyVaultDB(c.Request.Context(), h.stores, userID)
h.uekCache.Evict(userID)
h.auditLog(c, "user.vault_reset", "user", userID, models.JSONMap{
@@ -261,10 +258,7 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
return
}
if user.Role == models.UserRoleAdmin {
var adminCount int
database.DB.QueryRowContext(c.Request.Context(),
database.Q(`SELECT COUNT(*) FROM users WHERE role = $1`),
models.UserRoleAdmin).Scan(&adminCount)
adminCount, _ := h.stores.Users.CountByRole(c.Request.Context(), models.UserRoleAdmin)
if adminCount <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "cannot delete the last admin"})
return
@@ -769,10 +763,9 @@ func (h *AdminHandler) GetStats(c *gin.Context) {
ctx := c.Request.Context()
stats := gin.H{}
var userCount, channelCount, messageCount int
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&userCount)
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&channelCount)
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&messageCount)
userCount, _ := h.stores.Users.CountAll(ctx)
channelCount, _ := h.stores.Channels.CountAll(ctx)
messageCount, _ := h.stores.Messages.CountAll(ctx)
stats["users"] = userCount
stats["channels"] = channelCount

View File

@@ -20,7 +20,6 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -184,10 +183,7 @@ func (h *AuthHandler) OIDCLogin(c *gin.Context) {
}
// Store state for callback verification
_, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
INSERT INTO oidc_auth_state (state, nonce, redirect_to) VALUES ($1, $2, $3)
`), state, nonce, c.Query("redirect"))
if err != nil {
if err := h.stores.GlobalConfig.SaveOIDCState(c.Request.Context(), state, nonce, c.Query("redirect")); err != nil {
log.Printf("[auth/oidc] warn: could not store state: %v", err)
}
@@ -220,29 +216,15 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
return
}
// Verify state
var nonce, redirectTo string
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT nonce, COALESCE(redirect_to, '') FROM oidc_auth_state WHERE state = $1
`), state).Scan(&nonce, &redirectTo)
// Verify state (nonce + redirectTo retrieved but not yet validated — TODO)
_, _, err := h.stores.GlobalConfig.ConsumeOIDCState(c.Request.Context(), state)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired state"})
return
}
// Clean up state (one-time use)
database.DB.ExecContext(c.Request.Context(), database.Q(`
DELETE FROM oidc_auth_state WHERE state = $1
`), state)
// Also clean up stale states (> 10 minutes old)
if database.IsSQLite() {
database.DB.ExecContext(c.Request.Context(),
`DELETE FROM oidc_auth_state WHERE created_at < datetime('now', '-10 minutes')`)
} else {
database.DB.ExecContext(c.Request.Context(),
`DELETE FROM oidc_auth_state WHERE created_at < NOW() - INTERVAL '10 minutes'`)
}
// Clean up stale states (> 10 minutes old)
_ = h.stores.GlobalConfig.CleanupOIDCState(c.Request.Context())
// Determine redirect URI (must match what was sent in the login request)
redirectURI := h.cfg.OIDCRedirectURL
@@ -363,24 +345,17 @@ func hashToken(token string) string {
// - AdminHandler.destroyVault (admin-initiated reset)
//
// Does NOT evict from UEK cache or write audit logs — callers handle that.
func DestroyVaultDB(ctx context.Context, userID string) (providersDeleted int64) {
_, err := database.DB.ExecContext(ctx, database.Q(`
UPDATE users
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
WHERE id = $1
`), userID)
if err != nil {
// v0.29.0: accepts stores instead of using database.DB directly.
func DestroyVaultDB(ctx context.Context, stores store.Stores, userID string) (providersDeleted int64) {
if err := stores.Users.ClearVaultKeys(ctx, userID); err != nil {
log.Printf("⚠ DestroyVaultDB: failed to clear vault columns for user %s: %v", userID, err)
}
result, err := database.DB.ExecContext(ctx, database.Q(`
DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1
`), userID)
rows, err := stores.Providers.DeletePersonalByOwner(ctx, userID)
if err != nil {
log.Printf("⚠ DestroyVaultDB: failed to delete personal providers for user %s: %v", userID, err)
return 0
}
rows, _ := result.RowsAffected()
return rows
}
@@ -392,14 +367,9 @@ func DestroyVaultDB(ctx context.Context, userID string) (providersDeleted int64)
//
// Used by BootstrapAdmin and SeedUsers where the password is known at
// startup but the UEK cache is not available.
func ProbeAndRepairVault(ctx context.Context, userID, password string) {
var vaultSet bool
var encryptedUEK, salt, nonce []byte
err := database.DB.QueryRowContext(ctx, database.Q(`
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
FROM users WHERE id = $1
`), userID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce)
// v0.29.0: accepts stores instead of using database.DB directly.
func ProbeAndRepairVault(ctx context.Context, stores store.Stores, userID, password string) {
vaultSet, encryptedUEK, salt, nonce, err := stores.Users.GetVaultKeys(ctx, userID)
if err != nil || !vaultSet {
return // no vault to probe
}
@@ -410,7 +380,7 @@ func ProbeAndRepairVault(ctx context.Context, userID, password string) {
}
// Stale seal: password has actually changed since the vault was sealed
deleted := DestroyVaultDB(ctx, userID)
deleted := DestroyVaultDB(ctx, stores, userID)
if deleted > 0 {
log.Printf(" 🔐 Vault stale-seal repair for user %s: cleared %d personal provider(s)", userID, deleted)
} else {
@@ -442,12 +412,7 @@ func (h *AuthHandler) initVault(ctx context.Context, userID, password string) er
return err
}
_, err = database.DB.ExecContext(ctx, database.Q(`
UPDATE users
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, vault_set = true
WHERE id = $4
`), encryptedUEK, salt, nonce, userID)
if err != nil {
if err := h.stores.Users.InitVaultKeys(ctx, userID, encryptedUEK, salt, nonce); err != nil {
return err
}
@@ -464,13 +429,7 @@ func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, passwo
return
}
var vaultSet bool
var encryptedUEK, salt, nonce []byte
err := database.DB.QueryRowContext(ctx, database.Q(`
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
FROM users WHERE id = $1
`), user.ID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce)
vaultSet, encryptedUEK, salt, nonce, err := h.stores.Users.GetVaultKeys(ctx, user.ID)
if err != nil {
log.Printf("⚠ Vault query failed for user %s: %v", user.ID, err)
return
@@ -489,10 +448,7 @@ func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, passwo
uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk)
if err != nil {
// Stale seal: encrypted_uek was wrapped with a different password
// (e.g. admin reset, BootstrapAdmin rotation, or pre-UEK migration).
// The old UEK is irrecoverable. Destroy the vault and re-initialize
// with the current password so the user isn't permanently locked out.
deleted := DestroyVaultDB(ctx, user.ID)
deleted := DestroyVaultDB(ctx, h.stores, user.ID)
if deleted > 0 {
log.Printf("⚠ Vault stale-seal recovery for user %s: cleared %d personal provider(s)", user.ID, deleted)
} else {
@@ -536,7 +492,7 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) {
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(cfg.AdminUsername))
s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle})
}
ProbeAndRepairVault(ctx, existing.ID, cfg.AdminPassword)
ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword)
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
return
}
@@ -626,7 +582,7 @@ func SeedUsers(cfg *config.Config, s store.Stores) {
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username))
s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle})
}
ProbeAndRepairVault(ctx, existing.ID, password)
ProbeAndRepairVault(ctx, s, existing.ID, password)
log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role)
continue
}

View File

@@ -15,7 +15,8 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
const avatarSize = 128
@@ -76,10 +77,7 @@ func (h *SettingsHandler) UploadAvatar(c *gin.Context) {
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
// Store in DB
_, err = database.DB.Exec(
database.Q(`UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2`),
dataURI, userID,
)
err = h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{"avatar_url": dataURI})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
return
@@ -93,10 +91,7 @@ func (h *SettingsHandler) UploadAvatar(c *gin.Context) {
func (h *SettingsHandler) DeleteAvatar(c *gin.Context) {
userID := getUserID(c)
_, err := database.DB.Exec(
database.Q(`UPDATE users SET avatar_url = NULL, updated_at = NOW() WHERE id = $1`),
userID,
)
err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{"avatar_url": nil})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
return
@@ -166,7 +161,9 @@ func bilinearMix(c00, c10, c01, c11 color.Color, xf, yf float64) color.Color {
// ── Persona Avatar Upload ────────────────────
// POST /api/v1/personas/:id/avatar (user) or /api/v1/admin/personas/:id/avatar (admin)
func UploadPersonaAvatar(c *gin.Context) {
// UploadPersonaAvatar uploads and resizes a persona avatar.
// v0.29.0: accepts PersonaStore instead of using database.DB directly.
func UploadPersonaAvatar(personas store.PersonaStore, c *gin.Context) {
personaID := c.Param("id")
var req uploadAvatarRequest
@@ -206,40 +203,26 @@ func UploadPersonaAvatar(c *gin.Context) {
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
result, err := database.DB.Exec(
database.Q(`UPDATE personas SET avatar = $1, updated_at = NOW() WHERE id = $2`),
dataURI, personaID,
)
err = personas.Update(c.Request.Context(), personaID, models.PersonaPatch{Avatar: &dataURI})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
c.JSON(http.StatusOK, gin.H{"avatar": dataURI})
}
// DeletePersonaAvatar clears a persona's avatar.
func DeletePersonaAvatar(c *gin.Context) {
// v0.29.0: accepts PersonaStore instead of using database.DB directly.
func DeletePersonaAvatar(personas store.PersonaStore, c *gin.Context) {
personaID := c.Param("id")
result, err := database.DB.Exec(
database.Q(`UPDATE personas SET avatar = '', updated_at = NOW() WHERE id = $1`),
personaID,
)
empty := ""
err := personas.Update(c.Request.Context(), personaID, models.PersonaPatch{Avatar: &empty})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "avatar removed"})
}

View File

@@ -10,7 +10,6 @@ import (
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
@@ -90,10 +89,11 @@ func (h *ModelHandler) buildHealthMap(ctx context.Context) map[string]models.Pro
}
// ResolveModelCaps is the canonical capability resolver for any model.
func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapabilities {
// v0.29.0: accepts CatalogStore instead of using database.DB directly.
func ResolveModelCaps(catalog store.CatalogStore, modelID, configID string) models.ModelCapabilities {
// 1. Exact match: model_id + provider_config_id
if configID != "" {
caps, ok := capsFromCatalog(modelID, configID)
caps, ok := capsFromCatalog(catalog, modelID, configID)
if ok {
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
@@ -101,7 +101,7 @@ func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapa
}
// 2. Any provider: same model_id, any config
caps, ok := capsFromCatalog(modelID, "")
caps, ok := capsFromCatalog(catalog, modelID, "")
if ok {
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
@@ -114,23 +114,17 @@ func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapa
}
// capsFromCatalog looks up capabilities from the model_catalog table.
func capsFromCatalog(modelID, configID string) (models.ModelCapabilities, bool) {
if database.DB == nil {
func capsFromCatalog(catalog store.CatalogStore, modelID, configID string) (models.ModelCapabilities, bool) {
if catalog == nil {
return models.ModelCapabilities{}, false
}
var capsJSON []byte
var err error
if configID != "" {
err = database.DB.QueryRow(database.Q(`
SELECT capabilities FROM model_catalog
WHERE model_id = $1 AND provider_config_id = $2
`), modelID, configID).Scan(&capsJSON)
capsJSON, err = catalog.GetCapabilities(context.Background(), modelID, configID)
} else {
err = database.DB.QueryRow(database.Q(`
SELECT capabilities FROM model_catalog
WHERE model_id = $1 ORDER BY last_synced_at DESC LIMIT 1
`), modelID).Scan(&capsJSON)
capsJSON, err = catalog.GetCapabilitiesAny(context.Background(), modelID)
}
if err != nil || len(capsJSON) == 0 {
return models.ModelCapabilities{}, false

View File

@@ -1,7 +1,6 @@
package handlers
import (
"database/sql"
"encoding/json"
"math"
"net/http"
@@ -9,69 +8,68 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Request / Response types ────────────────
type createChannelRequest struct {
Title string `json:"title" binding:"required,max=500"`
Type string `json:"type,omitempty"` // direct (default), dm, group, channel, workflow
Description string `json:"description,omitempty"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
Folder string `json:"folder,omitempty"`
FolderID *string `json:"folder_id,omitempty"`
Tags []string `json:"tags,omitempty"`
Title string `json:"title" binding:"required,max=500"`
Type string `json:"type,omitempty"` // direct (default), dm, group, channel, workflow
Description string `json:"description,omitempty"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
Folder string `json:"folder,omitempty"`
FolderID *string `json:"folder_id,omitempty"`
Tags []string `json:"tags,omitempty"`
// v0.23.2: DM creation
Participants []string `json:"participants,omitempty"` // user IDs for DM (exactly 1 other user)
AiMode string `json:"ai_mode,omitempty"` // auto (default), mention_only, off
}
type updateChannelRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Model *string `json:"model,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"`
IsPinned *bool `json:"is_pinned,omitempty"`
Folder *string `json:"folder,omitempty"`
FolderID *string `json:"folder_id,omitempty"`
Tags []string `json:"tags,omitempty"`
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5)
AiMode *string `json:"ai_mode,omitempty"` // v0.23.2: auto, mention_only, off
Topic *string `json:"topic,omitempty"` // v0.23.2: channel topic
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Model *string `json:"model,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"`
IsPinned *bool `json:"is_pinned,omitempty"`
Folder *string `json:"folder,omitempty"`
FolderID *string `json:"folder_id,omitempty"`
Tags []string `json:"tags,omitempty"`
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5)
AiMode *string `json:"ai_mode,omitempty"` // v0.23.2: auto, mention_only, off
Topic *string `json:"topic,omitempty"` // v0.23.2: channel topic
}
type channelResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Type string `json:"type"`
AiMode string `json:"ai_mode,omitempty"`
Topic *string `json:"topic,omitempty"`
Description *string `json:"description"`
Model *string `json:"model"`
ProviderConfigID *string `json:"provider_config_id"`
SystemPrompt *string `json:"system_prompt"`
IsArchived bool `json:"is_archived"`
IsPinned bool `json:"is_pinned"`
Folder *string `json:"folder"`
FolderID *string `json:"folder_id,omitempty"`
ProjectID *string `json:"project_id,omitempty"`
WorkspaceID *string `json:"workspace_id,omitempty"`
Tags []string `json:"tags"`
Settings json.RawMessage `json:"settings,omitempty"`
MessageCount int `json:"message_count"`
UnreadCount int `json:"unread_count,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Type string `json:"type"`
AiMode string `json:"ai_mode,omitempty"`
Topic *string `json:"topic,omitempty"`
Description *string `json:"description"`
Model *string `json:"model"`
ProviderConfigID *string `json:"provider_config_id"`
SystemPrompt *string `json:"system_prompt"`
IsArchived bool `json:"is_archived"`
IsPinned bool `json:"is_pinned"`
Folder *string `json:"folder"`
FolderID *string `json:"folder_id,omitempty"`
ProjectID *string `json:"project_id,omitempty"`
WorkspaceID *string `json:"workspace_id,omitempty"`
Tags []string `json:"tags"`
Settings json.RawMessage `json:"settings,omitempty"`
MessageCount int `json:"message_count"`
UnreadCount int `json:"unread_count,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type paginatedResponse struct {
@@ -83,11 +81,13 @@ type paginatedResponse struct {
}
// ChannelHandler holds dependencies for channel endpoints.
type ChannelHandler struct{}
type ChannelHandler struct {
stores store.Stores
}
// NewChannelHandler creates a new channel handler.
func NewChannelHandler() *ChannelHandler {
return &ChannelHandler{}
func NewChannelHandler(stores store.Stores) *ChannelHandler {
return &ChannelHandler{stores: stores}
}
// channelDeleteHook is called after a channel is successfully deleted.
@@ -100,20 +100,6 @@ func SetChannelDeleteHook(fn func(channelID string)) {
channelDeleteHook = fn
}
// ── Tag Helpers ─────────────────────────────
// writeTagsArg returns a value suitable for inserting/updating the tags column.
// Postgres: pq.Array SQLite: JSON string
func writeTagsArg(tags []string) interface{} {
if database.IsSQLite() {
b, _ := json.Marshal(tags)
return string(b)
}
return pq.Array(tags)
}
// scanJSON, scanTags, SafeJSON → safe_json.go
// ── Helpers ─────────────────────────────────
// getUserID extracts the authenticated user's ID from context.
@@ -129,8 +115,7 @@ func isSessionAuth(c *gin.Context) bool {
}
// sessionCanAccessChannel validates that a session participant is authorized
// for the given channel. The AuthOrSession middleware already verifies this,
// but handlers call this as a defense-in-depth check.
// for the given channel.
func sessionCanAccessChannel(c *gin.Context, channelID string) bool {
if !isSessionAuth(c) {
return false
@@ -156,6 +141,38 @@ func parsePagination(c *gin.Context) (page, perPage, offset int) {
return
}
// listItemToResponse converts a store.ChannelListItem to a handler response.
func listItemToResponse(item store.ChannelListItem) channelResponse {
tags := item.Tags
if tags == nil {
tags = []string{}
}
return channelResponse{
ID: item.ID,
UserID: item.UserID,
Title: item.Title,
Type: item.Type,
AiMode: item.AiMode,
Topic: item.Topic,
Description: item.Description,
Model: item.Model,
ProviderConfigID: item.ProviderConfigID,
SystemPrompt: item.SystemPrompt,
IsArchived: item.IsArchived,
IsPinned: item.IsPinned,
Folder: item.Folder,
FolderID: item.FolderID,
ProjectID: item.ProjectID,
WorkspaceID: item.WorkspaceID,
Tags: tags,
Settings: item.Settings,
MessageCount: item.MessageCount,
UnreadCount: item.UnreadCount,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
// ── List Channels ───────────────────────────
func (h *ChannelHandler) ListChannels(c *gin.Context) {
@@ -164,11 +181,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
// Optional filters
archived := c.DefaultQuery("archived", "false")
folder := c.Query("folder")
folderID := c.Query("folder_id") // UUID — preferred over folder (text)
channelType := c.DefaultQuery("type", "") // empty = all types
// v0.23.1: multi-value type filter. Supports both ?types=dm&types=channel
// and comma-joined ?types=dm,channel
var channelTypes []string
if raw := c.Query("types"); raw != "" {
for _, t := range strings.Split(raw, ",") {
@@ -178,156 +191,31 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
}
}
}
search := strings.TrimSpace(c.Query("search"))
projectFilter := c.Query("project_id") // "uuid" or "none"
// Count total — include channels owned by user OR where user is a participant
countQuery := `SELECT COUNT(*) FROM channels c WHERE (c.user_id = $1 OR c.id IN (
SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1
)) AND c.is_archived = $2`
countArgs := []interface{}{userID, archived == "true"}
argN := 3
if len(channelTypes) > 0 {
placeholders := make([]string, len(channelTypes))
for i, t := range channelTypes {
placeholders[i] = "$" + strconv.Itoa(argN)
countArgs = append(countArgs, strings.TrimSpace(t))
argN++
if len(channelTypes) == 0 {
if ct := c.DefaultQuery("type", ""); ct != "" {
channelTypes = []string{ct}
}
countQuery += " AND c.type IN (" + strings.Join(placeholders, ",") + ")"
} else if channelType != "" {
countQuery += ` AND c.type = $` + strconv.Itoa(argN)
countArgs = append(countArgs, channelType)
argN++
}
if folder != "" {
countQuery += ` AND c.folder = $` + strconv.Itoa(argN)
countArgs = append(countArgs, folder)
argN++
}
if folderID != "" {
countQuery += ` AND c.folder_id = $` + strconv.Itoa(argN)
countArgs = append(countArgs, folderID)
argN++
}
if search != "" {
countQuery += ` AND c.title ILIKE $` + strconv.Itoa(argN)
countArgs = append(countArgs, "%"+search+"%")
argN++
}
if projectFilter == "none" {
countQuery += ` AND c.project_id IS NULL`
} else if projectFilter != "" {
countQuery += ` AND c.project_id = $` + strconv.Itoa(argN)
countArgs = append(countArgs, projectFilter)
argN++
}
var total int
if err := database.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"})
return
filter := store.ChannelListFilter{
ListOptions: store.ListOptions{Limit: perPage, Offset: offset},
Archived: archived == "true",
Types: channelTypes,
Folder: c.Query("folder"),
FolderID: c.Query("folder_id"),
Search: strings.TrimSpace(c.Query("search")),
ProjectID: c.Query("project_id"),
}
// Fetch channels with message count
// Include channels owned by user OR where user is a participant (multi-user)
query := `
SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic,
c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.folder_id, c.project_id, c.workspace_id,
c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
FROM channels c
LEFT JOIN (
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
) mc ON mc.channel_id = c.id
WHERE (c.user_id = $1 OR c.id IN (
SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1
)) AND c.is_archived = $2`
args := []interface{}{userID, archived == "true"}
argN = 3
if len(channelTypes) > 0 {
placeholders := make([]string, len(channelTypes))
for i, t := range channelTypes {
placeholders[i] = "$" + strconv.Itoa(argN)
args = append(args, strings.TrimSpace(t))
argN++
}
query += " AND c.type IN (" + strings.Join(placeholders, ",") + ")"
} else if channelType != "" {
query += ` AND c.type = $` + strconv.Itoa(argN)
args = append(args, channelType)
argN++
}
if folder != "" {
query += ` AND c.folder = $` + strconv.Itoa(argN)
args = append(args, folder)
argN++
}
if folderID != "" {
query += ` AND c.folder_id = $` + strconv.Itoa(argN)
args = append(args, folderID)
argN++
}
if search != "" {
query += ` AND c.title ILIKE $` + strconv.Itoa(argN)
args = append(args, "%"+search+"%")
argN++
}
if projectFilter == "none" {
query += ` AND c.project_id IS NULL`
} else if projectFilter != "" {
query += ` AND c.project_id = $` + strconv.Itoa(argN)
args = append(args, projectFilter)
argN++
}
query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
args = append(args, perPage, offset)
rows, err := database.Query(query, args...)
items, total, err := h.stores.Channels.ListFiltered(c.Request.Context(), userID, filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
return
}
defer rows.Close()
channels := make([]channelResponse, 0)
for rows.Next() {
var ch channelResponse
var tags []string
err := rows.Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
&ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID,
scanTags(&tags), scanJSON(&ch.Settings),
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan channel"})
return
}
if tags == nil {
tags = []string{}
}
ch.Tags = tags
channels = append(channels, ch)
}
rows.Close()
// v0.23.2: Compute unread counts per channel (safe post-processing)
for i := range channels {
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COUNT(*) FROM messages m
JOIN channel_participants cp ON cp.channel_id = m.channel_id
WHERE cp.channel_id = $1
AND cp.participant_type = 'user' AND cp.participant_id = $2
AND m.created_at > cp.last_read_at
`), channels[i].ID, userID).Scan(&channels[i].UnreadCount)
channels := make([]channelResponse, 0, len(items))
for _, item := range items {
channels = append(channels, listItemToResponse(item))
}
SafeJSON(c, http.StatusOK, paginatedResponse{
@@ -370,6 +258,8 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
}
}
ctx := c.Request.Context()
// ── DM dedup: check for existing DM between these two users ──
if channelType == "dm" && len(req.Participants) == 1 {
otherUserID := req.Participants[0]
@@ -378,162 +268,54 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
return
}
// Look for an existing DM channel where both users are participants
var existingID string
_ = database.DB.QueryRow(database.Q(`
SELECT cp1.channel_id FROM channel_participants cp1
JOIN channel_participants cp2 ON cp1.channel_id = cp2.channel_id
JOIN channels c ON c.id = cp1.channel_id
WHERE c.type = 'dm'
AND cp1.participant_type = 'user' AND cp1.participant_id = $1
AND cp2.participant_type = 'user' AND cp2.participant_id = $2
LIMIT 1
`), userID, otherUserID).Scan(&existingID)
existingID, _ := h.stores.Channels.FindExistingDM(ctx, userID, otherUserID)
if existingID != "" {
// Return existing channel instead of creating duplicate
var ch channelResponse
var tags []string
err := database.DB.QueryRow(database.Q(`
SELECT id, user_id, title, type, ai_mode, topic,
description, model, provider_config_id,
system_prompt, is_archived, is_pinned, folder, folder_id, project_id, workspace_id,
tags, settings,
created_at, updated_at
FROM channels WHERE id = $1
`), existingID).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
&ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID,
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
)
item, err := h.stores.Channels.GetForUser(ctx, existingID, userID)
if err == nil {
if tags == nil {
tags = []string{}
}
ch.Tags = tags
SafeJSON(c, http.StatusOK, ch) // 200, not 201 — existing resource
SafeJSON(c, http.StatusOK, listItemToResponse(*item)) // 200, not 201 — existing resource
return
}
// If read fails, fall through and create new (shouldn't happen)
}
}
// INSERT and retrieve the new row
var ch channelResponse
var tags []string
if database.IsSQLite() {
id := store.NewID()
_, err := database.DB.Exec(`
INSERT INTO channels (id, user_id, title, type, description, model,
system_prompt, provider_config_id, folder, folder_id, tags, ai_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, userID, req.Title, channelType, req.Description, req.Model,
req.SystemPrompt, req.ProviderConfigID, req.Folder, req.FolderID, writeTagsArg(req.Tags), aiMode,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
return
}
// Read back the row
err = database.DB.QueryRow(`
SELECT id, user_id, title, type, ai_mode, topic,
description, model, provider_config_id,
system_prompt, is_archived, is_pinned, folder, folder_id, project_id, workspace_id,
tags, settings,
created_at, updated_at
FROM channels WHERE id = ?`, id).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
&ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID,
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created channel: " + err.Error()})
return
}
} else {
err := database.DB.QueryRow(`
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, folder_id, tags, ai_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, user_id, title, type, ai_mode, topic,
description, model, provider_config_id, system_prompt,
is_archived, is_pinned, folder, folder_id, project_id, workspace_id, tags, settings, created_at, updated_at
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID,
req.Folder, req.FolderID, pq.Array(req.Tags), aiMode,
).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
&ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID,
pq.Array(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
return
}
// Build channel model
ch := &models.Channel{
UserID: userID,
Title: req.Title,
Type: channelType,
Description: req.Description,
Model: req.Model,
SystemPrompt: req.SystemPrompt,
ProviderConfigID: req.ProviderConfigID,
FolderID: req.FolderID,
}
if tags == nil {
tags = []string{}
}
ch.Tags = tags
ch.MessageCount = 0
// Auto-create channel_participant for the creator
if database.IsSQLite() {
_, _ = database.DB.Exec(`
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role)
VALUES (?, ?, 'user', ?, 'owner')
ON CONFLICT DO NOTHING
`, store.NewID(), ch.ID, userID)
} else {
_, _ = database.DB.Exec(`
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role)
VALUES ($1, 'user', $2, 'owner')
ON CONFLICT DO NOTHING
`, ch.ID, userID)
dmPartners := []string{}
if channelType == "dm" {
dmPartners = req.Participants
}
// v0.23.2: Add DM partner as member participant
if channelType == "dm" && len(req.Participants) > 0 {
for _, pid := range req.Participants {
if pid == userID {
continue // skip self (already added as owner)
}
if database.IsSQLite() {
_, _ = database.DB.Exec(`
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role)
VALUES (?, ?, 'user', ?, 'member')
ON CONFLICT DO NOTHING
`, store.NewID(), ch.ID, pid)
} else {
_, _ = database.DB.Exec(`
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role)
VALUES ($1, 'user', $2, 'member')
ON CONFLICT DO NOTHING
`, ch.ID, pid)
}
}
defaultConfigID := ""
if req.ProviderConfigID != nil {
defaultConfigID = *req.ProviderConfigID
}
// Auto-create channel_model if model specified
if req.Model != "" {
if database.IsSQLite() {
_, _ = database.DB.Exec(`
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, is_default)
VALUES (?, ?, ?, ?, 1)
ON CONFLICT DO NOTHING
`, store.NewID(), ch.ID, req.Model, req.ProviderConfigID)
} else {
_, _ = database.DB.Exec(`
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
VALUES ($1, $2, $3, true)
ON CONFLICT DO NOTHING
`, ch.ID, req.Model, req.ProviderConfigID)
}
if err := h.stores.Channels.CreateFull(ctx, ch, req.Folder, req.Tags, aiMode,
userID, dmPartners, req.Model, defaultConfigID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
return
}
SafeJSON(c, http.StatusCreated, ch)
// Fetch full response with message count
item, err := h.stores.Channels.GetForUser(ctx, ch.ID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created channel"})
return
}
SafeJSON(c, http.StatusCreated, listItemToResponse(*item))
}
// ── Get Channel ─────────────────────────────
@@ -542,45 +324,13 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
var ch channelResponse
var tags []string
err := database.DB.QueryRow(database.Q(`
SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic,
c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.folder_id, c.project_id, c.workspace_id,
c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
FROM channels c
LEFT JOIN (
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
) mc ON mc.channel_id = c.id
WHERE c.id = $1 AND (c.user_id = $2 OR c.id IN (
SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $2
))
`), channelID, userID).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
&ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID,
scanTags(&tags), scanJSON(&ch.Settings),
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
if err == sql.ErrNoRows {
item, err := h.stores.Channels.GetForUser(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get channel"})
return
}
if tags == nil {
tags = []string{}
}
ch.Tags = tags
SafeJSON(c, http.StatusOK, ch)
SafeJSON(c, http.StatusOK, listItemToResponse(*item))
}
// ── Update Channel ──────────────────────────
@@ -588,11 +338,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if database.DB == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"})
return
}
ctx := c.Request.Context()
var req updateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -601,79 +347,62 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
}
// Verify ownership
var ownerID string
err := database.DB.QueryRow(database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID).Scan(&ownerID)
if err == sql.ErrNoRows {
owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if ownerID != userID {
if !owns {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
// Build dynamic UPDATE with ? placeholders (converted for Postgres)
setClauses := []string{}
args := []interface{}{}
addClause := func(col string, val interface{}) {
setClauses = append(setClauses, col+" = ?")
args = append(args, val)
}
// Build fields map for store.Update
fields := map[string]interface{}{}
if req.Title != nil {
addClause("title", *req.Title)
fields["title"] = *req.Title
}
if req.Description != nil {
addClause("description", *req.Description)
fields["description"] = *req.Description
}
if req.Model != nil {
addClause("model", *req.Model)
fields["model"] = *req.Model
}
if req.SystemPrompt != nil {
addClause("system_prompt", *req.SystemPrompt)
fields["system_prompt"] = *req.SystemPrompt
}
if req.ProviderConfigID != nil {
addClause("provider_config_id", *req.ProviderConfigID)
if *req.ProviderConfigID == "" {
fields["provider_config_id"] = nil
} else {
fields["provider_config_id"] = *req.ProviderConfigID
}
}
if req.IsArchived != nil {
addClause("is_archived", *req.IsArchived)
fields["is_archived"] = *req.IsArchived
}
if req.IsPinned != nil {
addClause("is_pinned", *req.IsPinned)
fields["is_pinned"] = *req.IsPinned
}
if req.Folder != nil {
addClause("folder", *req.Folder)
fields["folder"] = *req.Folder
}
if req.FolderID != nil {
if *req.FolderID == "" {
addClause("folder_id", nil) // unbind from folder
fields["folder_id"] = nil // unbind from folder
} else {
addClause("folder_id", *req.FolderID)
fields["folder_id"] = *req.FolderID
}
}
if req.Tags != nil {
addClause("tags", writeTagsArg(req.Tags))
}
if req.Settings != nil {
// Validate settings is well-formed JSON before writing
if !json.Valid([]byte(*req.Settings)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "settings must be valid JSON"})
return
}
// JSONB merge: new settings keys overwrite existing, unmentioned keys preserved
if database.IsSQLite() {
setClauses = append(setClauses, "settings = json_patch(settings, ?)")
} else {
setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || ?::jsonb")
}
args = append(args, []byte(*req.Settings))
fields["tags"] = req.Tags
}
if req.WorkspaceID != nil {
if *req.WorkspaceID == "" {
addClause("workspace_id", nil) // unbind
fields["workspace_id"] = nil // unbind
} else {
addClause("workspace_id", *req.WorkspaceID)
fields["workspace_id"] = *req.WorkspaceID
}
}
if req.AiMode != nil {
@@ -682,29 +411,39 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "ai_mode must be auto, mention_only, or off"})
return
}
addClause("ai_mode", mode)
fields["ai_mode"] = mode
}
if req.Topic != nil {
addClause("topic", *req.Topic)
fields["topic"] = *req.Topic
}
if len(setClauses) == 0 {
hasFields := len(fields) > 0
hasSettings := req.Settings != nil
if !hasFields && !hasSettings {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
query := "UPDATE channels SET " + strings.Join(setClauses, ", ")
query += " WHERE id = ? AND user_id = ?"
args = append(args, channelID, userID)
if !database.IsSQLite() {
query = convertPlaceholders(query)
if hasSettings {
if !json.Valid([]byte(*req.Settings)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "settings must be valid JSON"})
return
}
}
_, err = database.DB.Exec(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
return
if hasFields {
if err := h.stores.Channels.Update(ctx, channelID, fields); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
return
}
}
if hasSettings {
if err := h.stores.Channels.MergeSettings(ctx, channelID, json.RawMessage(*req.Settings)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"})
return
}
}
// Return updated channel
@@ -717,17 +456,12 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
result, err := database.DB.Exec(
database.Q(`DELETE FROM channels WHERE id = $1 AND user_id = $2`),
channelID, userID,
)
n, err := h.stores.Channels.DeleteByOwner(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
@@ -746,30 +480,9 @@ func (h *ChannelHandler) MarkRead(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
// Update the read cursor timestamp (last_read_at exists since migration 005)
_, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE channel_participants
SET last_read_at = NOW()
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2
`), channelID, userID)
err := h.stores.Channels.MarkRead(c.Request.Context(), channelID, userID)
if err != nil {
// Participant row may not exist for legacy direct chats — that's OK
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
// Best-effort: also set last_read_message_id if the column exists (migration 017)
var latestMsgID *string
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1
`), channelID).Scan(&latestMsgID)
if latestMsgID != nil {
// This may fail if 017 hasn't been applied — that's fine, last_read_at is primary
_, _ = database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE channel_participants
SET last_read_message_id = $1
WHERE channel_id = $2 AND participant_type = 'user' AND participant_id = $3
`), *latestMsgID, channelID, userID)
}
c.JSON(http.StatusOK, gin.H{"ok": true})

View File

@@ -18,6 +18,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/filters"
"git.gobha.me/xcaliber/chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/models"
@@ -55,6 +56,7 @@ type CompletionHandler struct {
health HealthRecorder // provider health tracking (v0.22.0, nil = disabled)
healthStore HealthStatusQuerier // health status queries for routing (v0.22.2, nil = disabled)
router *routing.Evaluator // routing policy evaluator (v0.22.2, nil = disabled)
filterChain *filters.Chain // pre-completion filter chain (v0.29.0, nil = disabled)
}
// HealthRecorder is the interface for recording provider call outcomes.
@@ -94,6 +96,11 @@ func (h *CompletionHandler) SetRoutingEvaluator(r *routing.Evaluator) {
h.router = r
}
// SetFilterChain attaches the pre-completion filter chain (v0.29.0).
func (h *CompletionHandler) SetFilterChain(fc *filters.Chain) {
h.filterChain = fc
}
// evaluateRouting applies routing policies to select the best provider config
// for this request. Returns the winning config details and a routing decision
// for observability. If routing is disabled or no policies match, returns
@@ -430,7 +437,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// ── Team policy: require_private_providers ──
if err := enforcePrivateProviderPolicy(userID, configID); err != nil {
if err := enforcePrivateProviderPolicy(c.Request.Context(), h.stores, userID, configID); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -1092,7 +1099,7 @@ func escapeJSON(s string) string {
// getModelCapabilities looks up capabilities from model_catalog DB,
// then overlays with known model defaults and heuristic detection.
func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfigID string) models.ModelCapabilities {
return ResolveModelCaps(c, model, apiConfigID)
return ResolveModelCaps(h.stores.Catalog, model, apiConfigID)
}
// ── Multimodal Assembly ─────────────────────
@@ -1513,7 +1520,7 @@ func extractFirstMention(content string) string {
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, string, error) {
res, err := ResolveProviderConfig(h.vault, userID, channelID, req.ProviderConfigID, req.Model)
res, err := ResolveProviderConfig(h.stores, h.vault, userID, channelID, req.ProviderConfigID, req.Model)
if err != nil {
return providers.ProviderConfig{}, "", "", "", "", err
}
@@ -1576,12 +1583,22 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
}
}
// ── KB hint (nudge LLM to use kb_search when KBs are active) ──
if kbHint := BuildKBHint(context.Background(), h.stores, channelID, userID, personaID); kbHint != "" {
messages = append(messages, providers.Message{
Role: "system",
Content: kbHint,
})
// ── Pre-completion filter chain (v0.29.0) ──
// Runs registered filters (KB auto-inject, future extension filters).
// Replaces the inline BuildKBHint call. Each filter contributes zero
// or more system messages. Failures are logged and skipped.
if h.filterChain != nil && h.filterChain.Len() > 0 {
cc := &filters.CompletionContext{
ChannelID: channelID,
UserID: userID,
PersonaID: personaID,
}
for _, injected := range h.filterChain.Execute(context.Background(), cc) {
messages = append(messages, providers.Message{
Role: injected.Role,
Content: injected.Content,
})
}
}
// ── Memory hint (inject known facts about this user — v0.18.0) ──

View File

@@ -0,0 +1,213 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ExtPermHandler serves extension permission management endpoints.
// v0.29.0 CS2: Admin reviews and grants/revokes declared permissions.
type ExtPermHandler struct {
stores store.Stores
}
func NewExtPermHandler(stores store.Stores) *ExtPermHandler {
return &ExtPermHandler{stores: stores}
}
// ListPackagePermissions returns all declared permissions for a package.
// GET /api/v1/admin/extensions/:id/permissions
func (h *ExtPermHandler) ListPackagePermissions(c *gin.Context) {
pkgID := c.Param("id")
perms, err := h.stores.ExtPermissions.ListForPackage(c.Request.Context(), pkgID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list permissions"})
return
}
c.JSON(http.StatusOK, gin.H{"data": perms})
}
// GrantPermission grants a single declared permission for a package.
// POST /api/v1/admin/extensions/:id/permissions/:perm/grant
func (h *ExtPermHandler) GrantPermission(c *gin.Context) {
pkgID := c.Param("id")
perm := c.Param("perm")
userID := c.GetString("user_id")
if !models.ValidExtensionPermissions[perm] {
c.JSON(http.StatusBadRequest, gin.H{"error": "unrecognized permission: " + perm})
return
}
if err := h.stores.ExtPermissions.Grant(c.Request.Context(), pkgID, perm, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to grant permission"})
return
}
// Check if all permissions are now granted → activate package
h.maybeActivate(c, pkgID)
c.JSON(http.StatusOK, gin.H{"status": "granted", "package_id": pkgID, "permission": perm})
}
// RevokePermission revokes a single permission for a package.
// POST /api/v1/admin/extensions/:id/permissions/:perm/revoke
func (h *ExtPermHandler) RevokePermission(c *gin.Context) {
pkgID := c.Param("id")
perm := c.Param("perm")
if err := h.stores.ExtPermissions.Revoke(c.Request.Context(), pkgID, perm); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke permission"})
return
}
// Suspend if a previously-active package loses a grant
h.maybeSuspend(c, pkgID)
c.JSON(http.StatusOK, gin.H{"status": "revoked", "package_id": pkgID, "permission": perm})
}
// GrantAllPermissions grants all declared permissions for a package.
// POST /api/v1/admin/extensions/:id/permissions/grant-all
func (h *ExtPermHandler) GrantAllPermissions(c *gin.Context) {
pkgID := c.Param("id")
userID := c.GetString("user_id")
if err := h.stores.ExtPermissions.GrantAll(c.Request.Context(), pkgID, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to grant all permissions"})
return
}
// Activate the package now that all permissions are granted
h.maybeActivate(c, pkgID)
c.JSON(http.StatusOK, gin.H{"status": "granted_all", "package_id": pkgID})
}
// ReviewPackage returns the package with its declared permissions.
// GET /api/v1/admin/extensions/:id/review
func (h *ExtPermHandler) ReviewPackage(c *gin.Context) {
pkgID := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
perms, err := h.stores.ExtPermissions.ListForPackage(c.Request.Context(), pkgID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list permissions"})
return
}
c.JSON(http.StatusOK, gin.H{
"package": pkg,
"permissions": perms,
})
}
// ── Lifecycle helpers ────────────────────────
// maybeActivate transitions a pending_review package to active
// if all declared permissions have been granted.
func (h *ExtPermHandler) maybeActivate(c *gin.Context, pkgID string) {
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
return
}
if pkg.Status != models.PackageStatusPendingReview {
return
}
perms, err := h.stores.ExtPermissions.ListForPackage(c.Request.Context(), pkgID)
if err != nil {
return
}
allGranted := true
for _, p := range perms {
if !p.Granted {
allGranted = false
break
}
}
if allGranted {
_ = h.stores.Packages.SetStatus(c.Request.Context(), pkgID, models.PackageStatusActive)
}
}
// maybeSuspend transitions an active package to suspended
// if a required permission was revoked.
func (h *ExtPermHandler) maybeSuspend(c *gin.Context, pkgID string) {
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
return
}
if pkg.Status != models.PackageStatusActive {
return
}
perms, err := h.stores.ExtPermissions.ListForPackage(c.Request.Context(), pkgID)
if err != nil || len(perms) == 0 {
return
}
for _, p := range perms {
if !p.Granted {
_ = h.stores.Packages.SetStatus(c.Request.Context(), pkgID, models.PackageStatusSuspended)
return
}
}
}
// ── Manifest Permission Parsing ──────────────
// SyncManifestPermissions extracts the "permissions" array from a
// package manifest and declares them in the store. Returns the list
// of declared permissions. If the manifest declares permissions,
// the package status is set to pending_review.
//
// Called by AdminInstallExtension and InstallPackage handlers.
func SyncManifestPermissions(c *gin.Context, stores store.Stores, pkgID string, manifest map[string]any) []string {
if stores.ExtPermissions == nil {
return nil
}
raw, ok := manifest["permissions"]
if !ok {
return nil
}
arr, ok := raw.([]interface{})
if !ok {
return nil
}
var perms []string
for _, v := range arr {
if s, ok := v.(string); ok && models.ValidExtensionPermissions[s] {
perms = append(perms, s)
}
}
if len(perms) == 0 {
return nil
}
if err := stores.ExtPermissions.DeclareForPackage(c.Request.Context(), pkgID, perms); err != nil {
return nil
}
// Package needs review before activation
_ = stores.Packages.SetStatus(c.Request.Context(), pkgID, models.PackageStatusPendingReview)
return perms
}

View File

@@ -0,0 +1,105 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ExtSecretsHandler serves extension secret management endpoints.
// v0.29.0 CS3: Admin sets key-value secrets that Starlark extensions
// can read via the secrets.get() module.
//
// Secrets are stored in GlobalConfig under key "ext_secrets:{packageID}"
// as a JSON map: {"api_key": "sk-...", "webhook_token": "tok-..."}.
type ExtSecretsHandler struct {
stores store.Stores
}
func NewExtSecretsHandler(stores store.Stores) *ExtSecretsHandler {
return &ExtSecretsHandler{stores: stores}
}
// GetSecrets returns the secret keys (not values) for a package.
// GET /api/v1/admin/extensions/:id/secrets
func (h *ExtSecretsHandler) GetSecrets(c *gin.Context) {
pkgID := c.Param("id")
configKey := "ext_secrets:" + pkgID
configVal, err := h.stores.GlobalConfig.Get(c.Request.Context(), configKey)
if err != nil {
// Not found = empty secrets, not an error
c.JSON(http.StatusOK, gin.H{"data": map[string]string{}})
return
}
// Return only keys, not values (security: don't expose secrets in GET)
keys := make([]string, 0, len(configVal))
for k := range configVal {
keys = append(keys, k)
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{
"package_id": pkgID,
"keys": keys,
}})
}
// SetSecrets upserts secrets for a package.
// PUT /api/v1/admin/extensions/:id/secrets
// Body: {"secrets": {"api_key": "sk-...", "webhook_token": "tok-..."}}
func (h *ExtSecretsHandler) SetSecrets(c *gin.Context) {
pkgID := c.Param("id")
userID := c.GetString("user_id")
// Verify package exists
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
var body struct {
Secrets map[string]string `json:"secrets" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
return
}
// Store as GlobalConfig JSONMap
configKey := "ext_secrets:" + pkgID
configVal := models.JSONMap{}
for k, v := range body.Secrets {
configVal[k] = v
}
if err := h.stores.GlobalConfig.Set(c.Request.Context(), configKey, configVal, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save secrets"})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "saved",
"package_id": pkgID,
"key_count": len(body.Secrets),
})
}
// DeleteSecrets removes all secrets for a package.
// DELETE /api/v1/admin/extensions/:id/secrets
func (h *ExtSecretsHandler) DeleteSecrets(c *gin.Context) {
pkgID := c.Param("id")
userID := c.GetString("user_id")
configKey := "ext_secrets:" + pkgID
if err := h.stores.GlobalConfig.Set(c.Request.Context(), configKey, models.JSONMap{}, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete secrets"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "package_id": pkgID})
}

View File

@@ -206,6 +206,10 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
return
}
// v0.29.0: Parse manifest permissions and declare them.
// If permissions are declared, package moves to pending_review.
SyncManifestPermissions(c, h.stores, pkg.ID, manifestMap)
c.JSON(201, gin.H{"data": pkg})
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/extraction"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/storage"
@@ -162,10 +161,8 @@ func (h *FileHandler) Upload(c *gin.Context) {
return
}
// Update storage_key in PG
database.DB.ExecContext(c.Request.Context(),
database.Q(`UPDATE files SET storage_key = $1 WHERE id = $2`),
att.StorageKey, att.ID)
// Update storage_key
h.stores.Files.UpdateStorageKey(c.Request.Context(), att.ID, att.StorageKey)
// For images, mark extraction as not needed (complete immediately)
if strings.HasPrefix(contentType, "image/") {
@@ -463,14 +460,12 @@ func (h *FileHandler) CleanupChannelStorage(channelID string) {
// verifyChannelAccess checks that the requesting user owns the channel.
// Future RBAC (v0.20.0): replace with rbac.Can(userID, channelID, permission).
func (h *FileHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool {
var ownerID string
err := database.DB.QueryRowContext(c.Request.Context(),
database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID).Scan(&ownerID)
owns, err := h.stores.Channels.UserOwns(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return false
}
if ownerID != userID {
if !owns {
// Check if user is admin (admins can access any channel)
role, _ := c.Get("role")
if role != "admin" {
@@ -516,16 +511,9 @@ func (h *FileHandler) UploadToProject(c *gin.Context) {
// Verify project access (user owns or is team member; admins bypass)
if c.GetString("role") != "admin" {
var exists bool
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT EXISTS(
SELECT 1 FROM projects
WHERE id = $1 AND (owner_id = $2 OR team_id IN (
SELECT team_id FROM team_members WHERE user_id = $2 AND is_active = true
))
)
`), projectID, userID).Scan(&exists)
if err != nil || !exists {
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs)
if err != nil || !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
return
}
@@ -597,16 +585,9 @@ func (h *FileHandler) ListByProject(c *gin.Context) {
// Admins bypass project ownership/membership checks
if c.GetString("role") != "admin" {
var exists bool
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT EXISTS(
SELECT 1 FROM projects
WHERE id = $1 AND (owner_id = $2 OR team_id IN (
SELECT team_id FROM team_members WHERE user_id = $2 AND is_active = true
))
)
`), projectID, userID).Scan(&exists)
if err != nil || !exists {
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs)
if err != nil || !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
return
}

View File

@@ -2,61 +2,34 @@ package handlers
// folders.go — Chat folder CRUD (v0.23.1)
//
// Folders are user-scoped groupings for personal (direct) chats.
// Routes:
// GET /api/v1/folders
// POST /api/v1/folders
// PUT /api/v1/folders/:id
// DELETE /api/v1/folders/:id
// v0.29.0: Raw SQL replaced with FolderStore methods.
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type FolderHandler struct{}
type FolderHandler struct {
stores store.Stores
}
func NewFolderHandler() *FolderHandler { return &FolderHandler{} }
type folderRow struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID *string `json:"parent_id,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
func NewFolderHandler(s store.Stores) *FolderHandler {
return &FolderHandler{stores: s}
}
func (h *FolderHandler) List(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT id, name, parent_id, sort_order, created_at, updated_at
FROM folders
WHERE user_id = $1
ORDER BY sort_order, name
`), userID)
folders, err := h.stores.Folders.List(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
return
}
defer rows.Close()
var folders []folderRow
for rows.Next() {
var f folderRow
if err := rows.Scan(&f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt); err != nil {
continue
}
folders = append(folders, f)
}
if folders == nil {
folders = []folderRow{}
}
c.JSON(http.StatusOK, gin.H{"folders": folders})
}
@@ -76,14 +49,12 @@ func (h *FolderHandler) Create(c *gin.Context) {
return
}
var f folderRow
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
INSERT INTO folders (user_id, name, sort_order)
VALUES ($1, $2, $3)
RETURNING id, name, parent_id, sort_order, created_at, updated_at
`), userID, req.Name, req.SortOrder).Scan(
&f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt)
if err != nil {
f := &models.Folder{
UserID: userID,
Name: req.Name,
SortOrder: req.SortOrder,
}
if err := h.stores.Folders.Create(c.Request.Context(), f); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"})
return
}
@@ -105,17 +76,12 @@ func (h *FolderHandler) Update(c *gin.Context) {
req.Name = strings.TrimSpace(req.Name)
}
res, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE folders
SET name = COALESCE(NULLIF($3, ''), name),
sort_order = COALESCE($4, sort_order)
WHERE id = $1 AND user_id = $2
`), folderID, userID, req.Name, req.SortOrder)
n, err := h.stores.Folders.Update(c.Request.Context(), folderID, userID, req.Name, req.SortOrder)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update folder"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
return
}
@@ -127,18 +93,14 @@ func (h *FolderHandler) Delete(c *gin.Context) {
folderID := c.Param("id")
// Unassign chats before deleting folder
_, _ = database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE channels SET folder_id = NULL WHERE folder_id = $1 AND user_id = $2
`), folderID, userID)
_ = h.stores.Folders.UnassignChannels(c.Request.Context(), folderID, userID)
res, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
DELETE FROM folders WHERE id = $1 AND user_id = $2
`), folderID, userID)
n, err := h.stores.Folders.Delete(c.Request.Context(), folderID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete folder"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
return
}

View File

@@ -24,6 +24,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
"git.gobha.me/xcaliber/chat-switchboard/treepath"
)
// ── Test Harness ────────────────────────────
@@ -128,6 +129,7 @@ func setupHarness(t *testing.T) *testHarness {
} else {
stores = postgres.NewStores(database.TestDB)
}
treepath.Stores = &stores
userCache := middleware.NewUserStatusCache()
// Roles resolver (nil vault — test-fire won't work, but CRUD will)
@@ -175,7 +177,7 @@ func setupHarness(t *testing.T) *testHarness {
protected.GET("/teams/mine", teams.MyTeams)
teamScoped := protected.Group("/teams/:teamId")
teamScoped.Use(middleware.RequireTeamAdmin())
teamScoped.Use(middleware.RequireTeamAdmin(stores.Teams))
{
// Team members (team admin self-service)
teamScoped.GET("/members", teams.ListMembers)
@@ -235,7 +237,7 @@ func setupHarness(t *testing.T) *testHarness {
// users bypass the DB query so these tests work on both dialects when
// the caller is an admin. Non-admin member tests require Postgres.
teamMemberRoutes := protected.Group("/teams/:teamId")
teamMemberRoutes.Use(middleware.RequireTeamMember())
teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams))
{
teamMemberTaskH := NewTaskHandler(stores)
teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks)
@@ -247,7 +249,7 @@ func setupHarness(t *testing.T) *testHarness {
protected.GET("/usage", usage.PersonalUsage)
// Profile / Settings
settings := NewSettingsHandler(nil)
settings := NewSettingsHandler(stores, nil)
protected.GET("/profile", settings.GetProfile)
protected.PUT("/profile", settings.UpdateProfile)
protected.POST("/profile/password", settings.ChangePassword)
@@ -270,7 +272,7 @@ func setupHarness(t *testing.T) *testHarness {
protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0
// Persona Groups
pgH := NewPersonaGroupHandler()
pgH := NewPersonaGroupHandler(stores)
protected.GET("/persona-groups", pgH.List)
protected.POST("/persona-groups", pgH.Create)
protected.GET("/persona-groups/:id", pgH.Get)
@@ -288,7 +290,7 @@ func setupHarness(t *testing.T) *testHarness {
protected.DELETE("/notes/:id", notes.Delete)
// Channels
channels := NewChannelHandler()
channels := NewChannelHandler(stores)
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
protected.GET("/channels/:id", channels.GetChannel)
@@ -386,8 +388,8 @@ func setupHarness(t *testing.T) *testHarness {
admin.POST("/personas", personas.CreateAdminPersona)
admin.PUT("/personas/:id", personas.UpdateAdminPersona)
admin.DELETE("/personas/:id", personas.DeleteAdminPersona)
admin.POST("/personas/:id/avatar", UploadPersonaAvatar)
admin.DELETE("/personas/:id/avatar", DeletePersonaAvatar)
admin.POST("/personas/:id/avatar", func(c *gin.Context) { UploadPersonaAvatar(stores.Personas, c) })
admin.DELETE("/personas/:id/avatar", func(c *gin.Context) { DeletePersonaAvatar(stores.Personas, c) })
admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
admin.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
admin.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0

View File

@@ -847,6 +847,10 @@ func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID st
// BuildKBHint returns a system prompt fragment listing active KBs for a
// channel, or empty string if none are active. Called by the completion
// handler to nudge the LLM to use kb_search.
//
// Deprecated: v0.29.0 — replaced by filters.KBInjectFilter in the
// pre-completion filter chain. Kept for backward compatibility with
// any callers outside the main completion path. Will be removed in v0.30.0.
func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID, personaID string) string {
teamIDs, _ := stores.Teams.GetUserTeamIDs(ctx, userID)

View File

@@ -12,12 +12,13 @@ import (
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools"
"git.gobha.me/xcaliber/chat-switchboard/treepath"
)
// ── Request / Response types ────────────────
@@ -50,12 +51,12 @@ type editRequest struct {
}
type regenerateRequest struct {
Model string `json:"model,omitempty"`
PersonaID string `json:"persona_id,omitempty"`
ProviderConfigID string `json:"provider_config_id,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
DisabledTools []string `json:"disabled_tools,omitempty"`
Model string `json:"model,omitempty"`
PersonaID string `json:"persona_id,omitempty"`
ProviderConfigID string `json:"provider_config_id,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
DisabledTools []string `json:"disabled_tools,omitempty"`
}
type cursorRequest struct {
@@ -77,9 +78,6 @@ func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *even
// ── List Messages (flat, all branches) ──────
// GET /channels/:id/messages
//
// Returns all live messages across all branches (useful for admin/debug).
// Frontend should prefer /channels/:id/path for rendering.
func (h *MessageHandler) ListMessages(c *gin.Context) {
page, perPage, offset := parsePagination(c)
@@ -93,62 +91,37 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
} else if !userOwnsChannel(c, channelID, userID) {
} else if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
var total int
err := database.DB.QueryRow(
database.Q(`SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`),
channelID,
).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count messages"})
return
}
rows, err := database.DB.Query(database.Q(`
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)
WHEN m.participant_type = 'persona' THEN p.name
ELSE NULL END AS sender_name,
CASE WHEN m.participant_type = 'user' THEN u.avatar_url
WHEN m.participant_type = 'persona' THEN p.avatar
ELSE NULL END AS sender_avatar,
m.created_at
FROM messages m
LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text
LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text
WHERE m.channel_id = $1 AND m.deleted_at IS NULL
ORDER BY m.created_at ASC
LIMIT $2 OFFSET $3
`), channelID, perPage, offset)
ctx := c.Request.Context()
msgs, total, err := h.stores.Messages.ListWithSenderInfo(ctx, channelID, perPage, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"})
return
}
defer rows.Close()
messages := make([]messageResponse, 0)
for rows.Next() {
var msg messageResponse
err := rows.Scan(
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID,
&msg.SiblingIndex, &msg.ParticipantType, &msg.ParticipantID,
&msg.SenderName, &msg.SenderAvatar,
&msg.CreatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"})
return
}
messages = append(messages, msg)
messages := make([]messageResponse, 0, len(msgs))
for _, m := range msgs {
messages = append(messages, messageResponse{
ID: m.ID,
ChannelID: m.ChannelID,
Role: m.Role,
Content: m.Content,
Model: m.Model,
TokensUsed: m.TokensUsed,
ParentID: m.ParentID,
SiblingIndex: m.SiblingIndex,
ParticipantType: m.ParticipantType,
ParticipantID: m.ParticipantID,
SenderName: m.SenderName,
SenderAvatar: m.SenderAvatar,
CreatedAt: m.CreatedAt,
})
}
rows.Close() // release connection before sibling queries
// Enrich with sibling counts (requires DB access, must happen after rows are closed)
// Enrich with sibling counts
for i := range messages {
messages[i].SiblingCount = getSiblingCount(channelID, messages[i].ParentID)
}
@@ -164,15 +137,12 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
// ── Active Path ─────────────────────────────
// GET /channels/:id/path
//
// Returns the active branch from root → leaf with sibling metadata
// at each node. This is what the frontend renders.
func (h *MessageHandler) GetActivePath(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if !userOwnsChannel(c, channelID, userID) {
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
@@ -205,12 +175,11 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
} else if !userOwnsChannel(c, channelID, userID) {
} else if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Use cursor for parent, compute sibling_index
// Sessions use the same cursor logic (empty userID = channel-level latest)
effectiveUserID := userID
if isSessionAuth(c) {
effectiveUserID = c.GetString("session_id")
@@ -231,63 +200,43 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
}
}
var msg messageResponse
if database.IsSQLite() {
newID := store.NewID()
_, err := database.DB.Exec(`
INSERT INTO messages (id, channel_id, role, content, model, parent_id,
participant_type, participant_id, sibling_index)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, newID, channelID, req.Role, req.Content, req.Model, parentID,
participantType, participantID, siblingIdx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
return
}
err = database.DB.QueryRow(`
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
sibling_index, created_at
FROM messages WHERE id = ?`, newID).Scan(
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID,
&msg.SiblingIndex, &msg.CreatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created message"})
return
}
} else {
err := database.DB.QueryRow(`
INSERT INTO messages (channel_id, role, content, model, parent_id,
participant_type, participant_id, sibling_index)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, channel_id, role, content, model, tokens_used, parent_id,
sibling_index, created_at
`, channelID, req.Role, req.Content, req.Model, parentID,
participantType, participantID, siblingIdx,
).Scan(
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID,
&msg.SiblingIndex, &msg.CreatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
return
}
msg := &models.Message{
ChannelID: channelID,
Role: req.Role,
Content: req.Content,
Model: req.Model,
ParentID: parentID,
SiblingIndex: siblingIdx,
ParticipantType: participantType,
ParticipantID: participantID,
ToolCalls: models.JSONMap{},
Metadata: models.JSONMap{},
}
msg.SiblingCount = getSiblingCount(channelID, msg.ParentID)
_ = updateCursor(channelID, userID, msg.ID)
_, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID)
if err := h.stores.Messages.CreateWithCursor(c.Request.Context(), msg, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
return
}
c.JSON(http.StatusCreated, msg)
resp := messageResponse{
ID: msg.ID,
ChannelID: msg.ChannelID,
Role: msg.Role,
Content: msg.Content,
SiblingIndex: msg.SiblingIndex,
CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if msg.Model != "" {
resp.Model = &msg.Model
}
resp.ParentID = msg.ParentID
resp.SiblingCount = getSiblingCount(channelID, msg.ParentID)
c.JSON(http.StatusCreated, resp)
}
// ── Edit Message (create sibling) ───────────
// POST /channels/:id/messages/:msgId/edit
//
// Creates a new user message as a sibling of the target (same parent_id).
// Does NOT auto-trigger completion — the frontend sends a separate request.
func (h *MessageHandler) EditMessage(c *gin.Context) {
var req editRequest
@@ -300,18 +249,14 @@ func (h *MessageHandler) EditMessage(c *gin.Context) {
channelID := c.Param("id")
messageID := c.Param("msgId")
if !userOwnsChannel(c, channelID, userID) {
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Load target — must exist, belong to channel, be a user message
var targetParentID *string
var targetRole string
err := database.DB.QueryRow(database.Q(`
SELECT parent_id, role FROM messages
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
`), messageID, channelID).Scan(&targetParentID, &targetRole)
ctx := c.Request.Context()
// Load target — must exist, belong to channel, be a user message
targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
@@ -328,65 +273,39 @@ func (h *MessageHandler) EditMessage(c *gin.Context) {
// Create sibling: same parent_id as the target
siblingIdx := nextSiblingIndex(channelID, targetParentID)
var msg messageResponse
if database.IsSQLite() {
newID := store.NewID()
_, err = database.DB.Exec(`
INSERT INTO messages (id, channel_id, role, content, parent_id,
participant_type, participant_id, sibling_index)
VALUES (?, ?, 'user', ?, ?, 'user', ?, ?)
`, newID, channelID, req.Content, targetParentID, userID, siblingIdx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"})
return
}
err = database.DB.QueryRow(`
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
sibling_index, created_at
FROM messages WHERE id = ?`, newID).Scan(
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID,
&msg.SiblingIndex, &msg.CreatedAt,
)
} else {
err = database.DB.QueryRow(`
INSERT INTO messages (channel_id, role, content, parent_id,
participant_type, participant_id, sibling_index)
VALUES ($1, 'user', $2, $3, 'user', $4, $5)
RETURNING id, channel_id, role, content, model, tokens_used, parent_id,
sibling_index, created_at
`, channelID, req.Content, targetParentID, userID, siblingIdx,
).Scan(
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID,
&msg.SiblingIndex, &msg.CreatedAt,
)
msg := &models.Message{
ChannelID: channelID,
Role: "user",
Content: req.Content,
ParentID: targetParentID,
SiblingIndex: siblingIdx,
ParticipantType: "user",
ParticipantID: userID,
ToolCalls: models.JSONMap{},
Metadata: models.JSONMap{},
}
if err != nil {
if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"})
return
}
msg.SiblingCount = getSiblingCount(channelID, msg.ParentID)
// Cursor now points to the new sibling (it's a leaf — no children yet)
_ = updateCursor(channelID, userID, msg.ID)
_, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID)
resp := messageResponse{
ID: msg.ID,
ChannelID: msg.ChannelID,
Role: msg.Role,
Content: msg.Content,
ParentID: msg.ParentID,
SiblingIndex: msg.SiblingIndex,
SiblingCount: getSiblingCount(channelID, msg.ParentID),
CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
c.JSON(http.StatusCreated, msg)
c.JSON(http.StatusCreated, resp)
}
// ── Regenerate / Complete ──────────────────────
// POST /channels/:id/messages/:msgId/regenerate
//
// Two modes depending on target role:
// - assistant message → creates a NEW assistant sibling (same parent_id).
// Context is root → target's parent. ("Give me a different response.")
// - user message → creates a child assistant response.
// Context is root → target. ("Respond to this message.")
//
// The second mode is used after editing: the edit endpoint creates a
// sibling user message, then the frontend calls regenerate on it to
// get an assistant response without duplicating the user message.
func (h *MessageHandler) Regenerate(c *gin.Context) {
var req regenerateRequest
@@ -396,18 +315,14 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
channelID := c.Param("id")
messageID := c.Param("msgId")
if !userOwnsChannel(c, channelID, userID) {
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Load target message
var targetParentID *string
var targetRole string
err := database.DB.QueryRow(database.Q(`
SELECT parent_id, role FROM messages
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
`), messageID, channelID).Scan(&targetParentID, &targetRole)
ctx := c.Request.Context()
// Load target message
targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
@@ -421,13 +336,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
return
}
// Determine context path and new message's parent based on target role:
//
// assistant target: context = root → target's parent (exclude target)
// new parent = target's parent (sibling of target)
//
// user target: context = root → target (include target)
// new parent = target itself (child of target)
// Determine context path and new message's parent based on target role
var contextPath []PathMessage
var newParentID *string
@@ -486,15 +395,14 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// Fallback: channel's stored model
if model == "" {
var channelModel *string
_ = database.DB.QueryRow(database.Q(`SELECT model FROM channels WHERE id = $1`), channelID).Scan(&channelModel)
channelModel, _ := h.stores.Channels.GetDefaultModel(ctx, channelID)
if channelModel != nil {
model = *channelModel
}
}
providerCfg, providerID, model, configID, providerScope, err := comp.resolveConfig(userID, channelID, completionRequest{
Model: model,
Model: model,
ProviderConfigID: providerConfigID,
})
if err != nil {
@@ -514,8 +422,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
if personaSystemPrompt != "" {
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: personaSystemPrompt})
} else {
var systemPrompt *string
_ = database.DB.QueryRow(database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID).Scan(&systemPrompt)
systemPrompt, _ := h.stores.Channels.GetSystemPrompt(ctx, channelID)
if systemPrompt != nil && *systemPrompt != "" {
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: *systemPrompt})
}
@@ -544,14 +451,10 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
}
// Attach tool definitions (same as normal completion)
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(ctx, channelID)
// Query channel metadata for tool context and execution context
var chType string
var chTeamID *string
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
`), channelID).Scan(&chType, &chTeamID)
// Query channel metadata for tool context
chType, chTeamID, _ := h.stores.Channels.GetTypeAndTeamID(ctx, channelID)
msgTeamID := ""
if chTeamID != nil {
msgTeamID = *chTeamID
@@ -559,7 +462,6 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
// v0.25.0: Build ToolContext with channel metadata
tctx := tools.ToolContext{
ChannelType: chType,
WorkspaceID: workspaceID,
@@ -567,12 +469,11 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
PersonaID: personaID,
IsVisitor: isSessionAuth(c),
}
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
provReq.Tools = comp.buildToolDefs(ctx, userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
}
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
// ── Stream the response ──
// Apply provider-specific request hooks (v0.22.1)
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(providerCfg, &provReq)
}
@@ -583,47 +484,34 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
if result.Content != "" {
siblingIdx := nextSiblingIndex(channelID, newParentID)
// Match persistMessage pattern: nil interface{} → SQL NULL,
// non-nil string → valid JSONB. A nil json.RawMessage ([]byte)
// is sent by pq as empty bytes, not NULL, causing "invalid input
// syntax for type json".
var tcVal interface{}
var toolCalls models.JSONMap
if len(result.ToolActivity) > 0 {
b, _ := json.Marshal(result.ToolActivity)
tcVal = string(b)
_ = json.Unmarshal(b, &toolCalls)
}
if toolCalls == nil {
toolCalls = models.JSONMap{}
}
var newID string
if database.IsSQLite() {
newID = store.NewID()
_, err := database.DB.Exec(`
INSERT INTO messages (id, channel_id, role, content, model, tool_calls,
parent_id, participant_type, participant_id, sibling_index)
VALUES (?, ?, 'assistant', ?, ?, ?, ?, 'model', ?, ?)
`, newID, channelID, result.Content, model, tcVal, newParentID, model, siblingIdx)
if err != nil {
log.Printf("Failed to persist regenerated message: %v", err)
newID = ""
}
msg := &models.Message{
ChannelID: channelID,
Role: "assistant",
Content: result.Content,
Model: model,
ToolCalls: toolCalls,
Metadata: models.JSONMap{},
ParentID: newParentID,
SiblingIndex: siblingIdx,
ParticipantType: "model",
ParticipantID: model,
}
if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil {
log.Printf("Failed to persist regenerated message: %v", err)
} else {
err := database.DB.QueryRow(`
INSERT INTO messages (channel_id, role, content, model, tool_calls,
parent_id, participant_type, participant_id, sibling_index)
VALUES ($1, 'assistant', $2, $3, $4, $5, 'model', $6, $7)
RETURNING id
`, channelID, result.Content, model, tcVal, newParentID, model, siblingIdx).Scan(&newID)
if err != nil {
log.Printf("Failed to persist regenerated message: %v", err)
newID = ""
}
}
if newID != "" {
_ = updateCursor(channelID, userID, newID)
flusher, _ := c.Writer.(http.Flusher)
msgJSON, _ := json.Marshal(gin.H{
"id": newID,
"id": msg.ID,
"parent_id": newParentID,
"sibling_index": siblingIdx,
"sibling_count": getSiblingCount(channelID, newParentID),
@@ -633,8 +521,6 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
flusher.Flush()
}
}
_, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID)
}
// Log usage for regeneration
@@ -645,9 +531,6 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// ── Switch Branch (update cursor) ───────────
// PUT /channels/:id/cursor
//
// Navigates to a different branch. If the target message has children,
// follows the first-child chain to find the leaf.
func (h *MessageHandler) UpdateCursor(c *gin.Context) {
var req cursorRequest
@@ -659,17 +542,15 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if !userOwnsChannel(c, channelID, userID) {
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Verify the target message belongs to this channel
var msgChannelID string
err := database.DB.QueryRow(database.Q(`
SELECT channel_id FROM messages
WHERE id = $1 AND deleted_at IS NULL
`), req.ActiveLeafID).Scan(&msgChannelID)
ctx := c.Request.Context()
// Verify the target message belongs to this channel and is not deleted.
// GetParentAndRole does: WHERE id=$1 AND channel_id=$2 AND deleted_at IS NULL
_, _, err := h.stores.Messages.GetParentAndRole(ctx, req.ActiveLeafID, channelID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
@@ -678,10 +559,6 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify message"})
return
}
if msgChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "message does not belong to this channel"})
return
}
// Walk down to the leaf from the target
leafID, err := findLeafFromMessage(req.ActiveLeafID)
@@ -707,16 +584,13 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
// ── List Siblings ───────────────────────────
// GET /channels/:id/messages/:msgId/siblings
//
// Returns all siblings of a message (messages sharing the same parent_id).
// Used by the branch navigator to populate the arrow navigation.
func (h *MessageHandler) ListSiblings(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
messageID := c.Param("msgId")
if !userOwnsChannel(c, channelID, userID) {
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
@@ -733,36 +607,34 @@ func (h *MessageHandler) ListSiblings(c *gin.Context) {
})
}
// ── Ownership Check ─────────────────────────
// ── Ownership / Access Check ─────────────────
func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
var ownerID string
err := database.DB.QueryRow(
database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID,
).Scan(&ownerID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return false
}
// userCanAccessChannel verifies channel ownership or participation,
// writing an error response if denied. Returns true if access is allowed.
func userCanAccessChannel(c *gin.Context, stores store.Stores, channelID, userID string) bool {
ok, err := stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel ownership"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"})
return false
}
if ownerID == userID {
return true
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false
}
// v0.23.2: Also allow if user is a participant (multi-user DMs, channels)
var exists bool
_ = database.DB.QueryRow(
database.Q(`SELECT EXISTS(SELECT 1 FROM channel_participants WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2)`),
channelID, userID,
).Scan(&exists)
if exists {
return true
}
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false
return true
}
// userOwnsChannel is the legacy wrapper used by other handlers (completion.go).
// Delegates to userCanAccessChannel using the treepath global stores.
func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
ok, err := treepath.Stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"})
return false
}
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false
}
return true
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notelinks"
"git.gobha.me/xcaliber/chat-switchboard/store"
@@ -405,69 +404,30 @@ func (h *NoteHandler) Search(c *gin.Context) {
limit = 20
}
// Use Postgres full-text search when available, LIKE fallback on SQLite
if database.IsPostgres() {
h.searchPostgres(c, userID, q, limit)
return
}
// SQLite: use store's LIKE-based search
notes, _, err := h.stores.Notes.Search(c.Request.Context(), userID, q, store.ListOptions{Limit: limit})
// SearchKeyword handles dialect internally: PG uses ts_rank/ts_headline, SQLite uses LIKE.
storeResults, err := h.stores.Notes.SearchKeyword(c.Request.Context(), userID, q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
results := make([]searchResult, 0, len(notes))
for _, n := range notes {
results = append(results, searchResult{
noteListItem: toNoteListItem(n),
Rank: 1.0,
Headline: "",
})
}
c.JSON(http.StatusOK, gin.H{
"data": results,
"query": q,
"total": len(results),
})
}
// searchPostgres uses Postgres full-text search with ts_rank and ts_headline.
func (h *NoteHandler) searchPostgres(c *gin.Context, userID, q string, limit int) {
rows, err := database.DB.Query(`
SELECT id, title, folder_path, tags, LEFT(content, 200),
created_at::text, updated_at::text,
ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank,
ts_headline('english', content, plainto_tsquery('english', $2),
'MaxWords=40, MinWords=20, StartSel=**, StopSel=**') AS headline
FROM notes
WHERE user_id = $1
AND search_vector @@ plainto_tsquery('english', $2)
ORDER BY rank DESC
LIMIT $3
`, userID, q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
defer rows.Close()
// Import pq at call site to avoid pulling it in for SQLite builds
results := make([]searchResult, 0)
for rows.Next() {
var r searchResult
var tags []string
if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, pgScanStringArray(&tags), &r.Preview,
&r.CreatedAt, &r.UpdatedAt, &r.Rank, &r.Headline); err != nil {
continue
}
results := make([]searchResult, 0, len(storeResults))
for _, sr := range storeResults {
tags := sr.Tags
if tags == nil {
tags = []string{}
}
r.Tags = tags
results = append(results, r)
results = append(results, searchResult{
noteListItem: noteListItem{
ID: sr.ID,
Title: sr.Title,
FolderPath: sr.FolderPath,
Tags: tags,
Preview: sr.Excerpt,
},
Rank: sr.Rank,
Headline: sr.Headline,
})
}
c.JSON(http.StatusOK, gin.H{
@@ -483,29 +443,19 @@ func (h *NoteHandler) searchPostgres(c *gin.Context, userID, q string, limit int
func (h *NoteHandler) ListFolders(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.Query(database.Q(`
SELECT DISTINCT folder_path, COUNT(*) AS count
FROM notes WHERE user_id = $1
GROUP BY folder_path
ORDER BY folder_path
`), userID)
storeFolders, err := h.stores.Notes.ListFolders(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
return
}
defer rows.Close()
type folderInfo struct {
Path string `json:"path"`
Count int `json:"count"`
}
folders := make([]folderInfo, 0)
for rows.Next() {
var f folderInfo
if err := rows.Scan(&f.Path, &f.Count); err != nil {
continue
}
folders = append(folders, f)
folders := make([]folderInfo, 0, len(storeFolders))
for _, f := range storeFolders {
folders = append(folders, folderInfo{Path: f.Path, Count: f.Count})
}
c.JSON(http.StatusOK, gin.H{"folders": folders})

View File

@@ -6,7 +6,6 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -260,17 +259,10 @@ func (h *ParticipantHandler) Remove(c *gin.Context) {
}
// v0.23.2: DM guard — cannot drop below 2 human participants
var channelType string
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COALESCE(type, 'direct') FROM channels WHERE id = $1
`), channelID).Scan(&channelType)
channelType, _, _ := h.stores.Channels.GetTypeAndTeamID(c.Request.Context(), channelID)
if channelType == "dm" && p.ParticipantType == "user" {
var userCount int
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COUNT(*) FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID).Scan(&userCount)
userCount, _ := h.stores.Channels.CountParticipantsByType(c.Request.Context(), channelID, "user")
if userCount <= 2 {
c.JSON(http.StatusConflict, gin.H{"error": "DMs require at least 2 participants"})
return
@@ -279,11 +271,7 @@ func (h *ParticipantHandler) Remove(c *gin.Context) {
// v0.23.2: Group guard — cannot remove the last persona
if channelType == "group" && p.ParticipantType == "persona" {
var personaCount int
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COUNT(*) FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'persona'
`), channelID).Scan(&personaCount)
personaCount, _ := h.stores.Channels.CountParticipantsByType(c.Request.Context(), channelID, "persona")
if personaCount <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "group chats require at least 1 persona"})
return

View File

@@ -2,64 +2,40 @@ package handlers
// persona_groups.go — Persona group (roster template) CRUD (v0.23.2)
//
// Persona groups are saved collections of personas used as templates
// for creating group chats. Each member has an is_leader flag.
//
// Routes:
// GET /api/v1/persona-groups
// POST /api/v1/persona-groups
// GET /api/v1/persona-groups/:id
// PUT /api/v1/persona-groups/:id
// DELETE /api/v1/persona-groups/:id
// POST /api/v1/persona-groups/:id/members
// DELETE /api/v1/persona-groups/:id/members/:memberId
// v0.29.0: Raw SQL replaced with PersonaGroupStore methods.
import (
"database/sql"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type PersonaGroupHandler struct{}
type PersonaGroupHandler struct {
stores store.Stores
}
func NewPersonaGroupHandler() *PersonaGroupHandler { return &PersonaGroupHandler{} }
func NewPersonaGroupHandler(s store.Stores) *PersonaGroupHandler {
return &PersonaGroupHandler{stores: s}
}
// ── List ────────────────────────────────────────
func (h *PersonaGroupHandler) List(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
FROM persona_groups
WHERE owner_id = $1
ORDER BY name
`), userID)
groups, err := h.stores.PersonaGroups.List(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groups"})
return
}
defer rows.Close()
groups := []models.PersonaGroup{}
for rows.Next() {
var g models.PersonaGroup
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)); err != nil {
continue
}
groups = append(groups, g)
}
// Load members for each group
for i := range groups {
groups[i].Members = h.loadMembers(c, groups[i].ID)
members, _ := h.stores.PersonaGroups.ListMembers(c.Request.Context(), groups[i].ID)
groups[i].Members = members
}
c.JSON(http.StatusOK, gin.H{"data": groups})
@@ -71,25 +47,22 @@ func (h *PersonaGroupHandler) Get(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
var g models.PersonaGroup
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
FROM persona_groups WHERE id = $1 AND owner_id = $2
`), id, userID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt))
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
g, err := h.stores.PersonaGroups.Get(c.Request.Context(), id, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get group"})
return
}
g.Members = h.loadMembers(c, g.ID)
if g.Members == nil {
g.Members = []models.PersonaGroupMember{}
if g == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
members, _ := h.stores.PersonaGroups.ListMembers(c.Request.Context(), g.ID)
if members == nil {
members = []models.PersonaGroupMember{}
}
g.Members = members
c.JSON(http.StatusOK, g)
}
@@ -107,34 +80,16 @@ func (h *PersonaGroupHandler) Create(c *gin.Context) {
return
}
var g models.PersonaGroup
if database.IsSQLite() {
id := store.NewID()
_, err := database.DB.ExecContext(c.Request.Context(), `
INSERT INTO persona_groups (id, name, description, owner_id, scope)
VALUES (?, ?, ?, ?, 'personal')
`, id, strings.TrimSpace(req.Name), req.Description, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
return
}
database.DB.QueryRowContext(c.Request.Context(), `
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
FROM persona_groups WHERE id = ?
`, id).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt))
} else {
err := database.DB.QueryRowContext(c.Request.Context(), `
INSERT INTO persona_groups (name, description, owner_id, scope)
VALUES ($1, $2, $3, 'personal')
RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at
`, strings.TrimSpace(req.Name), req.Description, userID).Scan(
&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
return
}
g := &models.PersonaGroup{
Name: strings.TrimSpace(req.Name),
Description: req.Description,
OwnerID: userID,
Scope: "personal",
}
if err := h.stores.PersonaGroups.Create(c.Request.Context(), g); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
return
}
g.Members = []models.PersonaGroupMember{}
@@ -157,11 +112,8 @@ func (h *PersonaGroupHandler) Update(c *gin.Context) {
}
// Verify ownership
var ownerID string
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT owner_id FROM persona_groups WHERE id = $1
`), id).Scan(&ownerID)
if err == sql.ErrNoRows {
ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), id)
if err != nil || ownerID == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
@@ -170,15 +122,15 @@ func (h *PersonaGroupHandler) Update(c *gin.Context) {
return
}
fields := map[string]interface{}{}
if req.Name != nil {
database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE persona_groups SET name = $1, updated_at = NOW() WHERE id = $2
`), strings.TrimSpace(*req.Name), id)
fields["name"] = strings.TrimSpace(*req.Name)
}
if req.Description != nil {
database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE persona_groups SET description = $1, updated_at = NOW() WHERE id = $2
`), *req.Description, id)
fields["description"] = *req.Description
}
if len(fields) > 0 {
_ = h.stores.PersonaGroups.Update(c.Request.Context(), id, fields)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
@@ -190,14 +142,11 @@ func (h *PersonaGroupHandler) Delete(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
result, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
DELETE FROM persona_groups WHERE id = $1 AND owner_id = $2
`), id, userID)
n, err := h.stores.PersonaGroups.Delete(c.Request.Context(), id, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
n, _ := result.RowsAffected()
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
@@ -221,37 +170,13 @@ func (h *PersonaGroupHandler) AddMember(c *gin.Context) {
}
// Verify ownership
var ownerID string
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT owner_id FROM persona_groups WHERE id = $1
`), groupID).Scan(&ownerID)
ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), groupID)
if err != nil || ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
return
}
// If setting as leader, clear existing leader
if req.IsLeader {
database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE persona_group_members SET is_leader = false WHERE group_id = $1
`), groupID)
}
if database.IsSQLite() {
id := store.NewID()
_, err = database.DB.ExecContext(c.Request.Context(), `
INSERT INTO persona_group_members (id, group_id, persona_id, is_leader)
VALUES (?, ?, ?, ?)
ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = excluded.is_leader
`, id, groupID, req.PersonaID, req.IsLeader)
} else {
_, err = database.DB.ExecContext(c.Request.Context(), `
INSERT INTO persona_group_members (group_id, persona_id, is_leader)
VALUES ($1, $2, $3)
ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = EXCLUDED.is_leader
`, groupID, req.PersonaID, req.IsLeader)
}
if err != nil {
if err := h.stores.PersonaGroups.AddMember(c.Request.Context(), groupID, req.PersonaID, req.IsLeader); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"})
return
}
@@ -266,49 +191,12 @@ func (h *PersonaGroupHandler) RemoveMember(c *gin.Context) {
groupID := c.Param("id")
memberID := c.Param("memberId")
// Verify ownership
var ownerID string
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT owner_id FROM persona_groups WHERE id = $1
`), groupID).Scan(&ownerID)
ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), groupID)
if err != nil || ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
return
}
database.DB.ExecContext(c.Request.Context(), database.Q(`
DELETE FROM persona_group_members WHERE id = $1 AND group_id = $2
`), memberID, groupID)
_ = h.stores.PersonaGroups.RemoveMember(c.Request.Context(), memberID, groupID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── Helpers ─────────────────────────────────────
func (h *PersonaGroupHandler) loadMembers(c *gin.Context, groupID string) []models.PersonaGroupMember {
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order,
COALESCE(p.name, '') AS persona_name,
COALESCE(p.handle, '') AS persona_handle,
COALESCE(p.avatar, '') AS persona_avatar
FROM persona_group_members pgm
LEFT JOIN personas p ON p.id = pgm.persona_id
WHERE pgm.group_id = $1
ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id
`), groupID)
if err != nil {
return []models.PersonaGroupMember{}
}
defer rows.Close()
members := []models.PersonaGroupMember{}
for rows.Next() {
var m models.PersonaGroupMember
if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder,
&m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil {
continue
}
members = append(members, m)
}
return members
}

View File

@@ -450,7 +450,7 @@ func (h *PersonaHandler) UploadUserPersonaAvatar(c *gin.Context) {
}
// Delegate to the shared avatar handler
UploadPersonaAvatar(c)
UploadPersonaAvatar(h.stores.Personas, c)
}
// DeleteUserPersonaAvatar deletes an avatar for a personal persona,
@@ -471,7 +471,7 @@ func (h *PersonaHandler) DeleteUserPersonaAvatar(c *gin.Context) {
}
// Delegate to the shared avatar handler
DeletePersonaAvatar(c)
DeletePersonaAvatar(h.stores.Personas, c)
}
// ── Team-Scoped Helpers ─────────────────────
@@ -556,7 +556,7 @@ func (h *PersonaHandler) UploadTeamPersonaAvatar(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
UploadPersonaAvatar(c)
UploadPersonaAvatar(h.stores.Personas, c)
}
// DeleteTeamPersonaAvatar deletes an avatar for a team persona,
@@ -565,5 +565,5 @@ func (h *PersonaHandler) DeleteTeamPersonaAvatar(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
DeletePersonaAvatar(c)
DeletePersonaAvatar(h.stores.Personas, c)
}

View File

@@ -5,6 +5,8 @@ package handlers
// Clients POST /api/v1/presence/heartbeat every 30s while active.
// GET /api/v1/presence?users=id1,id2 returns current status.
// Online = last_seen within 90s.
//
// v0.29.0: Raw SQL replaced with PresenceStore + UserStore methods.
import (
"net/http"
@@ -13,21 +15,23 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
const presenceOnlineThreshold = 90 * time.Second
type PresenceHandler struct {
stores store.Stores
}
func NewPresenceHandler(s store.Stores) *PresenceHandler {
return &PresenceHandler{stores: s}
}
// PresenceHeartbeat upserts the calling user's last_seen timestamp.
func PresenceHeartbeat(c *gin.Context) {
func (h *PresenceHandler) Heartbeat(c *gin.Context) {
userID := getUserID(c)
_, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
INSERT INTO user_presence (user_id, last_seen, status)
VALUES ($1, NOW(), 'online')
ON CONFLICT (user_id) DO UPDATE
SET last_seen = NOW(), status = 'online'
`), userID)
if err != nil {
if err := h.stores.Presence.Heartbeat(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "presence update failed"})
return
}
@@ -36,7 +40,7 @@ func PresenceHeartbeat(c *gin.Context) {
// PresenceQuery returns online/offline status for a list of user IDs.
// Query param: ?users=uuid1,uuid2,...
func PresenceQuery(c *gin.Context) {
func (h *PresenceHandler) Query(c *gin.Context) {
raw := c.Query("users")
if raw == "" {
c.JSON(http.StatusOK, gin.H{"presence": map[string]string{}})
@@ -47,71 +51,34 @@ func PresenceQuery(c *gin.Context) {
ids = ids[:100]
}
threshold := time.Now().Add(-presenceOnlineThreshold)
result := make(map[string]string, len(ids))
// Trim whitespace
cleaned := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
var lastSeen time.Time
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT last_seen FROM user_presence WHERE user_id = $1
`), id).Scan(&lastSeen)
if err != nil || lastSeen.Before(threshold) {
result[id] = "offline"
} else {
result[id] = "online"
if id != "" {
cleaned = append(cleaned, id)
}
}
threshold := time.Now().Add(-presenceOnlineThreshold)
result, err := h.stores.Presence.GetStatuses(c.Request.Context(), cleaned, threshold)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "presence query failed"})
return
}
c.JSON(http.StatusOK, gin.H{"presence": result})
}
// SearchUsers returns a lightweight list of approved users matching a query.
// GET /api/v1/users/search?q=alice — matches against username and display_name.
// Returns at most 20 results. Excludes the calling user.
func SearchUsers(c *gin.Context) {
// GET /api/v1/users/search?q=alice
func (h *PresenceHandler) SearchUsers(c *gin.Context) {
userID := getUserID(c)
q := strings.TrimSpace(c.Query("q"))
query := database.Q(`
SELECT id, username, COALESCE(display_name, '') AS display_name, COALESCE(handle, '') AS handle
FROM users
WHERE is_active = true AND id != $1
`)
args := []interface{}{userID}
if q != "" {
query += database.Q(` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3 OR LOWER(handle) LIKE $4)`)
pattern := "%" + strings.ToLower(q) + "%"
args = append(args, pattern, pattern, pattern)
}
query += ` ORDER BY username LIMIT 20`
rows, err := database.DB.QueryContext(c.Request.Context(), query, args...)
results, err := h.stores.Users.SearchActive(c.Request.Context(), userID, q)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
defer rows.Close()
type userResult struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Handle string `json:"handle"`
}
results := []userResult{}
for rows.Next() {
var u userResult
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle); err != nil {
continue
}
results = append(results, u)
}
c.JSON(http.StatusOK, gin.H{"users": results})
}

View File

@@ -47,7 +47,7 @@ func setupProfileHarness(t *testing.T) *profileHarness {
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
settings := NewSettingsHandler(nil)
settings := NewSettingsHandler(stores, nil)
protected.GET("/profile", settings.GetProfile)
protected.PUT("/profile", settings.UpdateProfile)
protected.POST("/profile/password", settings.ChangePassword)

View File

@@ -6,7 +6,6 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -381,24 +380,11 @@ func (h *ProjectHandler) AdminList(c *gin.Context) {
ctx := c.Request.Context()
includeArchived := c.Query("include_archived") == "true"
// Admin sees everything — query with no user scope
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT p.id, p.name, p.description, p.scope,
p.owner_id, p.team_id, p.is_archived,
p.created_at, p.updated_at,
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
(SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id),
(SELECT COUNT(*) FROM project_notes WHERE project_id = p.id),
u.username
FROM projects p
LEFT JOIN users u ON u.id = p.owner_id
WHERE ($1 OR p.is_archived = false)
ORDER BY p.updated_at DESC`), includeArchived)
projects, err := h.stores.Projects.AdminList(ctx, includeArchived)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"})
return
}
defer rows.Close()
type adminProject struct {
ID string `json:"id"`
@@ -416,25 +402,25 @@ func (h *ProjectHandler) AdminList(c *gin.Context) {
OwnerName string `json:"owner_name"`
}
var projects []adminProject
for rows.Next() {
var p adminProject
if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Scope,
&p.OwnerID, &p.TeamID, &p.IsArchived,
&p.CreatedAt, &p.UpdatedAt,
&p.ChannelCount, &p.KBCount, &p.NoteCount,
&p.OwnerName,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "scan error"})
return
}
projects = append(projects, p)
result := make([]adminProject, 0, len(projects))
for _, p := range projects {
result = append(result, adminProject{
ID: p.ID,
Name: p.Name,
Description: p.Description,
Scope: p.Scope,
OwnerID: p.OwnerID,
TeamID: p.TeamID,
IsArchived: p.IsArchived,
CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z"),
ChannelCount: p.ChannelCount,
KBCount: p.KBCount,
NoteCount: p.NoteCount,
OwnerName: p.OwnerName,
})
}
if projects == nil {
projects = []adminProject{}
}
c.JSON(http.StatusOK, gin.H{"data": projects})
c.JSON(http.StatusOK, gin.H{"data": result})
}
// ── Auth Helper ─────────────────────────────

View File

@@ -6,6 +6,8 @@
//
// The CompletionHandler methods (resolveConfig, buildToolDefs) remain as
// thin wrappers for backward compat — existing callers are unchanged.
//
// v0.29.0-cs7a: Replaced raw SQL with store methods.
package handlers
import (
@@ -16,7 +18,6 @@ import (
"log"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools"
@@ -41,17 +42,16 @@ type ProviderResolution struct {
//
// The vault is used to decrypt API keys. Pass nil for unencrypted fallback.
func ResolveProviderConfig(
stores store.Stores,
vault *crypto.KeyResolver,
userID, channelID, providerConfigID, modelID string,
) (ProviderResolution, error) {
ctx := context.Background()
configID := providerConfigID
// 2. Config from channel
if configID == "" && channelID != "" {
var channelConfigID *string
err := database.DB.QueryRow(
database.Q(`SELECT provider_config_id FROM channels WHERE id = $1`), channelID,
).Scan(&channelConfigID)
channelConfigID, err := stores.Channels.GetProviderConfigID(ctx, channelID)
if err == nil && channelConfigID != nil {
configID = *channelConfigID
}
@@ -59,47 +59,15 @@ func ResolveProviderConfig(
// 3. User's first active config (personal first, then global — excludes team providers)
if configID == "" {
err := database.DB.QueryRow(database.Q(`
SELECT id FROM provider_configs
WHERE is_active = true AND (
(scope = 'personal' AND owner_id = $1)
OR scope = 'global'
)
ORDER BY scope ASC, created_at ASC
LIMIT 1
`), userID).Scan(&configID)
var err error
configID, err = stores.Providers.FindFirstForUser(ctx, userID)
if err != nil {
return ProviderResolution{}, fmt.Errorf("no API config found — add one at /api-configs")
}
}
// Load the config — allow personal, global, OR team configs the user belongs to
var providerID, endpoint string
var providerScope string
var modelDefault *string
var apiKeyEnc, keyNonce []byte
var keyScope string
var customHeadersJSON, providerSettingsJSON []byte
var proxyMode string
var proxyURL *string
// $2/userID appears twice in the query; Postgres reuses positional params,
// SQLite needs each ? bound separately.
configArgs := []interface{}{configID, userID}
if database.IsSQLite() {
configArgs = append(configArgs, userID)
}
err := database.DB.QueryRow(database.Q(`
SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope,
model_default, headers, settings, COALESCE(proxy_mode, 'system'), proxy_url
FROM provider_configs
WHERE id = $1 AND is_active = true
AND (scope = 'global'
OR (scope = 'personal' AND owner_id = $2)
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
`), configArgs...).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
&modelDefault, &customHeadersJSON, &providerSettingsJSON, &proxyMode, &proxyURL)
cfg, err := stores.Providers.LoadAccessible(ctx, configID, userID)
if err == sql.ErrNoRows {
return ProviderResolution{}, fmt.Errorf("API config not found or not accessible")
}
@@ -109,8 +77,8 @@ func ResolveProviderConfig(
// Resolve model: explicit > config default
model := modelID
if model == "" && modelDefault != nil {
model = *modelDefault
if model == "" && cfg.ModelDefault != "" {
model = cfg.ModelDefault
}
if model == "" {
return ProviderResolution{}, fmt.Errorf("no model specified and no default model in config")
@@ -118,10 +86,10 @@ func ResolveProviderConfig(
// Decrypt API key using the appropriate tier
key := ""
if len(apiKeyEnc) > 0 {
if len(cfg.APIKeyEnc) > 0 {
if vault != nil {
var err error
key, err = vault.Decrypt(apiKeyEnc, keyNonce, keyScope, userID)
key, err = vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, userID)
if err != nil {
if err == crypto.ErrVaultLocked {
return ProviderResolution{}, fmt.Errorf("personal vault is locked — please log in again")
@@ -130,40 +98,47 @@ func ResolveProviderConfig(
}
} else {
// No vault — key stored as raw bytes (unencrypted fallback)
key = string(apiKeyEnc)
key = string(cfg.APIKeyEnc)
}
}
// Parse custom headers
customHeaders := make(map[string]string)
if customHeadersJSON != nil {
_ = json.Unmarshal(customHeadersJSON, &customHeaders)
if cfg.Headers != nil {
b, _ := json.Marshal(cfg.Headers)
_ = json.Unmarshal(b, &customHeaders)
}
// Parse provider-specific settings
providerSettings := make(map[string]interface{})
if providerSettingsJSON != nil {
_ = json.Unmarshal(providerSettingsJSON, &providerSettings)
if cfg.Settings != nil {
for k, v := range cfg.Settings {
providerSettings[k] = v
}
}
proxyMode := cfg.ProxyMode
if proxyMode == "" {
proxyMode = "system"
}
proxyURLStr := ""
if proxyURL != nil {
proxyURLStr = *proxyURL
if cfg.ProxyURL != nil {
proxyURLStr = *cfg.ProxyURL
}
return ProviderResolution{
Config: providers.ProviderConfig{
Endpoint: endpoint,
Endpoint: cfg.Endpoint,
APIKey: key,
CustomHeaders: customHeaders,
Settings: providerSettings,
ProxyMode: proxyMode,
ProxyURL: proxyURLStr,
},
ProviderID: providerID,
ProviderID: cfg.Provider,
Model: model,
ConfigID: configID,
ProviderScope: providerScope,
ConfigID: cfg.ID,
ProviderScope: cfg.Scope,
}, nil
}

View File

@@ -59,7 +59,7 @@ func TestRouteRegistration(t *testing.T) {
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
// Channels (the route group that conflicts with workflow)
channels := NewChannelHandler()
channels := NewChannelHandler(stores)
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
protected.GET("/channels/:id", channels.GetChannel)
@@ -93,7 +93,7 @@ func TestRouteRegistration(t *testing.T) {
protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
// Workflow assignments (v0.26.4)
wfAssignH := NewWorkflowAssignmentHandler()
wfAssignH := NewWorkflowAssignmentHandler(stores)
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)

View File

@@ -1,7 +1,11 @@
package handlers
// settings.go — User profile, preferences, and password management.
//
// v0.29.0: Raw SQL replaced with UserStore methods.
import (
"database/sql"
"context"
"encoding/json"
"log"
"net/http"
@@ -12,7 +16,7 @@ import (
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Request Types ───────────────────────────
@@ -41,12 +45,13 @@ type profileResponse struct {
// SettingsHandler manages user profile and preferences.
type SettingsHandler struct {
stores store.Stores
uekCache *crypto.UEKCache
}
// NewSettingsHandler creates a new handler.
func NewSettingsHandler(uekCache *crypto.UEKCache) *SettingsHandler {
return &SettingsHandler{uekCache: uekCache}
func NewSettingsHandler(stores store.Stores, uekCache *crypto.UEKCache) *SettingsHandler {
return &SettingsHandler{stores: stores, uekCache: uekCache}
}
// ── Get Profile ─────────────────────────────
@@ -54,31 +59,37 @@ func NewSettingsHandler(uekCache *crypto.UEKCache) *SettingsHandler {
func (h *SettingsHandler) GetProfile(c *gin.Context) {
userID := getUserID(c)
var p profileResponse
var settingsRaw string
err := database.DB.QueryRow(database.Q(`
SELECT id, username, email, display_name, role, avatar_url,
COALESCE(NULLIF(settings::text, 'null'), '{}'),
created_at, last_login_at
FROM users WHERE id = $1
`), userID).Scan(
&p.ID, &p.Username, &p.Email, &p.DisplayName, &p.Role,
&p.Avatar, &settingsRaw,
database.ST(&p.CreatedAt), database.SNT(&p.LastLoginAt),
)
if err == sql.ErrNoRows {
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load profile"})
return
var dn *string
if user.DisplayName != "" {
dn = &user.DisplayName
}
var avatar *string
if user.AvatarURL != "" {
avatar = &user.AvatarURL
}
p.Settings = make(map[string]interface{})
_ = json.Unmarshal([]byte(settingsRaw), &p.Settings)
settings := make(map[string]interface{})
if user.Settings != nil {
settings = user.Settings
}
c.JSON(http.StatusOK, p)
c.JSON(http.StatusOK, profileResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
DisplayName: dn,
Role: user.Role,
Avatar: avatar,
Settings: settings,
CreatedAt: user.CreatedAt,
LastLoginAt: user.LastLoginAt,
})
}
// ── Update Profile ──────────────────────────
@@ -93,11 +104,9 @@ func (h *SettingsHandler) UpdateProfile(c *gin.Context) {
}
if req.DisplayName != nil {
_, err := database.DB.Exec(
database.Q(`UPDATE users SET display_name = $1, updated_at = NOW() WHERE id = $2`),
*req.DisplayName, userID,
)
if err != nil {
if err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{
"display_name": *req.DisplayName,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update display name"})
return
}
@@ -105,12 +114,11 @@ func (h *SettingsHandler) UpdateProfile(c *gin.Context) {
if req.Email != nil {
email := strings.ToLower(strings.TrimSpace(*req.Email))
_, err := database.DB.Exec(
database.Q(`UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2`),
email, userID,
)
err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{
"email": email,
})
if err != nil {
if database.IsUniqueViolation(err) {
if isDuplicateErr(err) {
c.JSON(http.StatusConflict, gin.H{"error": "email already taken"})
return
}
@@ -133,40 +141,31 @@ func (h *SettingsHandler) ChangePassword(c *gin.Context) {
return
}
// Verify current password
var hash string
err := database.DB.QueryRow(
database.Q(`SELECT password_hash FROM users WHERE id = $1`), userID,
).Scan(&hash)
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify password"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.CurrentPassword)); err != nil {
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "current password is incorrect"})
return
}
// Hash new password
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcryptCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
_, err = database.DB.Exec(
database.Q(`UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`),
string(newHash), userID,
)
if err != nil {
if err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{
"password_hash": string(newHash),
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update password"})
return
}
// Re-wrap UEK with new password so personal BYOK keys remain accessible
h.rewrapVault(userID, req.CurrentPassword, req.NewPassword)
c.JSON(http.StatusOK, gin.H{"message": "password updated"})
}
@@ -175,20 +174,16 @@ func (h *SettingsHandler) ChangePassword(c *gin.Context) {
func (h *SettingsHandler) GetSettings(c *gin.Context) {
userID := getUserID(c)
var settingsRaw string
// Handle three states: SQL NULL, JSON null, or valid JSON object.
// NULLIF(settings, 'null') converts JSON null to SQL NULL,
// then COALESCE handles both SQL NULL cases.
err := database.DB.QueryRow(
database.Q(`SELECT COALESCE(NULLIF(settings::text, 'null'), '{}') FROM users WHERE id = $1`), userID,
).Scan(&settingsRaw)
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
return
}
settings := make(map[string]interface{})
_ = json.Unmarshal([]byte(settingsRaw), &settings)
if user.Settings != nil {
settings = user.Settings
}
c.JSON(http.StatusOK, gin.H{"settings": settings})
}
@@ -210,28 +205,7 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
return
}
// JSONB merge — existing keys preserved, incoming keys overwrite.
// Must handle three column states:
// 1. SQL NULL (never saved)
// 2. JSON literal null (corrupted by earlier bug)
// 3. JSON array (corrupted by repeated appends to null)
// 4. Valid JSON object (normal)
// NULLIF converts JSON null → SQL NULL, then COALESCE → '{}',
// and we enforce jsonb_typeof = 'object' to catch array corruption.
var mergeQuery string
if database.IsSQLite() {
mergeQuery = `UPDATE users SET settings = json_patch(
CASE WHEN settings IS NULL OR settings = 'null' OR json_type(settings) != 'object'
THEN '{}' ELSE settings END,
?), updated_at = datetime('now') WHERE id = ?`
} else {
mergeQuery = `UPDATE users SET settings = (
CASE WHEN settings IS NULL OR settings = 'null'::jsonb OR jsonb_typeof(settings) != 'object'
THEN '{}'::jsonb ELSE settings END
) || $1::jsonb, updated_at = NOW() WHERE id = $2`
}
_, err = database.DB.Exec(mergeQuery, string(patch), userID)
if err != nil {
if err := h.stores.Users.MergeSettings(c.Request.Context(), userID, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"})
return
}
@@ -241,25 +215,16 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
// ── Vault Re-wrap ──────────────────────────
// rewrapVault decrypts the UEK with the old password and re-encrypts it with
// the new password. This keeps personal BYOK keys accessible after a password
// change. Uses a new salt for forward secrecy.
func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) {
if h.uekCache == nil {
return
}
var vaultSet bool
var encUEK, salt, nonce []byte
err := database.DB.QueryRow(database.Q(`
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
FROM users WHERE id = $1
`), userID).Scan(&vaultSet, &encUEK, &salt, &nonce)
vaultSet, encUEK, salt, nonce, err := h.stores.Users.GetVaultKeys(c_bg(), userID)
if err != nil || !vaultSet || len(encUEK) == 0 {
return // No vault to re-wrap
return
}
// Decrypt UEK with old password
oldPDK := crypto.DeriveKeyFromPassword(oldPassword, salt)
uek, err := crypto.UnwrapUEK(encUEK, nonce, oldPDK)
if err != nil {
@@ -267,7 +232,6 @@ func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) {
return
}
// Re-wrap with new password using fresh salt
newSalt, err := crypto.GenerateSalt()
if err != nil {
log.Printf("⚠ Vault re-wrap failed for user %s (salt): %v", userID, err)
@@ -281,17 +245,15 @@ func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) {
return
}
_, err = database.DB.Exec(database.Q(`
UPDATE users
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, updated_at = NOW()
WHERE id = $4
`), newEncUEK, newSalt, newNonce, userID)
if err != nil {
if err := h.stores.Users.UpdateVaultKeys(c_bg(), userID, newEncUEK, newSalt, newNonce); err != nil {
log.Printf("⚠ Vault re-wrap failed for user %s (persist): %v", userID, err)
return
}
// Session cache: UEK itself hasn't changed, just its wrapper
h.uekCache.Store(userID, uek)
log.Printf(" 🔐 Vault re-wrapped for user %s", userID)
}
// c_bg returns context.Background() — short alias for vault operations
// that run outside the request lifecycle.
func c_bg() context.Context { return context.Background() }

View File

@@ -1,45 +1,44 @@
package handlers
// summarize.go — Manual conversation summarization via HTTP.
//
// v0.29.0: Raw SQL replaced with ChannelStore methods.
import (
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/compaction"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// SummarizeHandler handles manual conversation summarization via HTTP.
type SummarizeHandler struct {
stores store.Stores
compaction *compaction.Service
}
// NewSummarizeHandler creates a new handler backed by the compaction service.
func NewSummarizeHandler(svc *compaction.Service) *SummarizeHandler {
return &SummarizeHandler{compaction: svc}
func NewSummarizeHandler(stores store.Stores, svc *compaction.Service) *SummarizeHandler {
return &SummarizeHandler{stores: stores, compaction: svc}
}
// ── Summarize & Continue ──────────────────
// POST /channels/:id/summarize
//
// User-triggered compaction: calls the utility role to summarize the
// conversation history, inserts the summary as a tree node, and returns it.
func (h *SummarizeHandler) Summarize(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
// ── Verify channel ownership ──
var ownerID string
err := database.DB.QueryRow(
database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID,
).Scan(&ownerID)
if err != nil {
ch, err := h.stores.Channels.GetByID(c.Request.Context(), channelID)
if err != nil || ch == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if ownerID != userID {
if ch.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
@@ -73,7 +72,6 @@ func (h *SummarizeHandler) Summarize(c *gin.Context) {
Trigger: "manual",
})
if err != nil {
// Map specific errors to HTTP status codes
switch err.Error() {
case "conversation too short to summarize":
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})

View File

@@ -689,11 +689,11 @@ func TestTask_WorkflowTypeRejected(t *testing.T) {
// Task Type RBAC (v0.28.7)
// ═══════════════════════════════════════════════
func TestTask_StarlarkTypeRejected(t *testing.T) {
func TestTask_StarlarkTypeRequiresPackageID(t *testing.T) {
h := setupHarness(t)
_, token := h.createAdminUser("staruser", "staruser@test.com")
// Even admin cannot create starlark tasks — executor doesn't exist yet
// Starlark tasks require system_function (package_id)
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
"name": "Starlark Task",
"task_type": "starlark",
@@ -702,11 +702,11 @@ func TestTask_StarlarkTypeRejected(t *testing.T) {
"model_id": "m",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("starlark task_type: want 400, got %d: %s", w.Code, w.Body.String())
t.Fatalf("starlark task_type without package_id: want 400, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "v0.29.0") {
t.Fatalf("expected 'v0.29.0' in error, got: %s", body)
if !strings.Contains(body, "system_function") && !strings.Contains(body, "package_id") {
t.Fatalf("expected 'system_function' or 'package_id' in error, got: %s", body)
}
}

View File

@@ -133,12 +133,30 @@ func (h *TaskHandler) Create(c *gin.Context) {
}
}
// v0.28.7: Starlark tasks — pre-positioned gate for v0.29.0.
// The Starlark executor does not exist yet. Reject all creation with
// a clear message rather than a cryptic CHECK constraint failure.
// v0.29.0: Starlark tasks — run sandboxed extension scripts.
// Requires task.starlark permission. system_function holds package ID.
if t.TaskType == "starlark" {
c.JSON(http.StatusBadRequest, gin.H{"error": "starlark task execution requires v0.29.0 — not yet available"})
return
if c.GetString("role") != "admin" {
perms := middleware.GetResolvedPermissions(c)
if perms == nil || !perms[auth.PermTaskStarlark] {
c.JSON(http.StatusForbidden, gin.H{"error": "permission required: " + auth.PermTaskStarlark})
return
}
}
if t.SystemFunction == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "system_function (package_id) is required for starlark tasks"})
return
}
// Verify package exists and is starlark tier
pkg, err := h.stores.Packages.Get(c.Request.Context(), t.SystemFunction)
if err != nil || pkg == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "package not found: " + t.SystemFunction})
return
}
if pkg.Tier != models.ExtTierStarlark {
c.JSON(http.StatusBadRequest, gin.H{"error": "package " + t.SystemFunction + " is not a starlark package"})
return
}
}
// v0.28.0-audit: Workflow task execution is not yet implemented.

View File

@@ -1,6 +1,7 @@
package handlers
import (
"context"
"encoding/json"
"log"
"net/http"
@@ -8,9 +9,9 @@ import (
"github.com/gin-gonic/gin"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Team Provider Handlers ──────────────────
@@ -18,19 +19,13 @@ import (
// ListTeamProviders returns API configs scoped to a team.
func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
teamID := getTeamID(c)
ctx := c.Request.Context()
rows, err := database.DB.Query(database.Q(`
SELECT id, name, provider, endpoint, api_key_enc,
model_default, config, is_active, is_private, created_at, updated_at
FROM provider_configs
WHERE scope = 'team' AND owner_id = $1
ORDER BY name ASC
`), teamID)
configs, err := h.stores.Providers.ListAllForTeam(ctx, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team providers"})
return
}
defer rows.Close()
type teamProvider struct {
ID string `json:"id"`
@@ -46,23 +41,34 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
UpdatedAt string `json:"updated_at"`
}
configs := make([]teamProvider, 0)
for rows.Next() {
var p teamProvider
var apiKeyEnc []byte
var configRaw string
if err := rows.Scan(&p.ID, &p.Name, &p.Provider, &p.Endpoint, &apiKeyEnc,
&p.ModelDefault, &configRaw, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt); err != nil {
continue
result := make([]teamProvider, 0, len(configs))
for _, cfg := range configs {
var md *string
if cfg.ModelDefault != "" {
md = &cfg.ModelDefault
}
p.HasKey = len(apiKeyEnc) > 0
p.Config = parseJSONBConfig(configRaw)
configs = append(configs, p)
cfgMap := map[string]interface{}{}
if cfg.Config != nil {
cfgMap = cfg.Config
}
result = append(result, teamProvider{
ID: cfg.ID,
Name: cfg.Name,
Provider: cfg.Provider,
Endpoint: cfg.Endpoint,
HasKey: cfg.HasKey(),
ModelDefault: md,
Config: cfgMap,
IsActive: cfg.IsActive,
IsPrivate: cfg.IsPrivate,
CreatedAt: cfg.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z"),
})
}
c.JSON(http.StatusOK, gin.H{
"data": configs,
"allow_team_providers": isTeamProvidersAllowed(teamID),
"data": result,
"allow_team_providers": isTeamProvidersAllowed(h.stores, teamID),
})
}
@@ -70,7 +76,7 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
if !isTeamProvidersAllowed(teamID) {
if !isTeamProvidersAllowed(h.stores, teamID) {
c.JSON(http.StatusForbidden, gin.H{"error": "team providers are not enabled for this team"})
return
}
@@ -98,18 +104,6 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
return
}
configJSON := "{}"
if req.Config != nil {
b, _ := json.Marshal(req.Config)
configJSON = string(b)
}
headersJSON := "{}"
if req.Headers != nil {
b, _ := json.Marshal(req.Headers)
headersJSON = string(b)
}
// Encrypt the API key for team scope
var apiKeyEnc, keyNonce []byte
if req.APIKey != "" {
@@ -125,27 +119,43 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
}
}
id, err := database.InsertReturningID(`
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint,
api_key_enc, key_nonce, key_scope, model_default, config, is_private, headers)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'team', $8, $9::jsonb, $10, $11::jsonb)
RETURNING id
`, "team", teamID, req.Name, req.Provider, req.Endpoint,
apiKeyEnc, keyNonce, req.ModelDefault, configJSON, req.IsPrivate, headersJSON,
)
if err != nil {
headersMap := models.JSONMap{}
if req.Headers != nil {
for k, v := range req.Headers {
headersMap[k] = v
}
}
cfg := &models.ProviderConfig{
Scope: models.ScopeTeam,
OwnerID: &teamID,
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
APIKeyEnc: apiKeyEnc,
KeyNonce: keyNonce,
KeyScope: "team",
ModelDefault: req.ModelDefault,
Config: models.JSONMap(req.Config),
Headers: headersMap,
IsActive: true,
IsPrivate: req.IsPrivate,
}
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
log.Printf("[WARN] Failed to create team provider: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create provider"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id})
c.JSON(http.StatusCreated, gin.H{"id": cfg.ID})
}
// UpdateTeamProvider updates a team-scoped API config.
func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
ctx := c.Request.Context()
var req struct {
Name *string `json:"name,omitempty"`
@@ -163,29 +173,23 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
}
// Verify provider belongs to this team
var count int
database.DB.QueryRow(database.Q(`SELECT COUNT(*) FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2`), providerID, teamID).Scan(&count)
if count == 0 {
existing, err := h.stores.Providers.GetByID(ctx, providerID)
if err != nil || existing.Scope != models.ScopeTeam || (existing.OwnerID != nil && *existing.OwnerID != teamID) {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"})
return
}
// Build dynamic update using ? placeholders (works on both dialects)
setClauses := []string{"updated_at = " + database.Q("NOW()")}
args := []interface{}{}
// Build patch
patch := models.ProviderConfigPatch{}
fieldCount := 0
addSet := func(col string, val interface{}) {
setClauses = append(setClauses, col+" = ?")
args = append(args, val)
if req.Name != nil {
patch.Name = req.Name
fieldCount++
}
if req.Name != nil {
addSet("name", *req.Name)
}
if req.Endpoint != nil {
addSet("endpoint", *req.Endpoint)
patch.Endpoint = req.Endpoint
fieldCount++
}
if req.APIKey != nil && *req.APIKey != "" {
if h.vault != nil {
@@ -194,28 +198,36 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
addSet("api_key_enc", enc)
addSet("key_nonce", nonce)
patch.APIKeyEnc = enc
patch.KeyNonce = nonce
} else {
addSet("api_key_enc", []byte(*req.APIKey))
patch.APIKeyEnc = []byte(*req.APIKey)
}
fieldCount++
}
if req.ModelDefault != nil {
addSet("model_default", *req.ModelDefault)
patch.ModelDefault = req.ModelDefault
fieldCount++
}
if req.IsActive != nil {
addSet("is_active", *req.IsActive)
patch.IsActive = req.IsActive
fieldCount++
}
if req.IsPrivate != nil {
addSet("is_private", *req.IsPrivate)
patch.IsPrivate = req.IsPrivate
fieldCount++
}
if req.Config != nil {
b, _ := json.Marshal(req.Config)
addSet("config", string(b))
patch.Config = models.JSONMap(req.Config)
fieldCount++
}
if req.Headers != nil {
b, _ := json.Marshal(req.Headers)
addSet("headers", string(b))
headersMap := models.JSONMap{}
for k, v := range req.Headers {
headersMap[k] = v
}
patch.Headers = headersMap
fieldCount++
}
if fieldCount == 0 {
@@ -223,24 +235,7 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
return
}
args = append(args, providerID, teamID)
// Build final query with ? placeholders, then convert to $N for Postgres
query := "UPDATE provider_configs SET "
for i, s := range setClauses {
if i > 0 {
query += ", "
}
query += s
}
query += " WHERE id = ? AND scope = 'team' AND owner_id = ?"
if database.IsPostgres() {
query = convertPlaceholders(query)
}
_, err := database.DB.Exec(query, args...)
if err != nil {
if err := h.stores.Providers.Update(ctx, providerID, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update provider"})
return
}
@@ -253,16 +248,12 @@ func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
result, err := database.DB.Exec(database.Q(`
DELETE FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2
`), providerID, teamID)
n, err := h.stores.Providers.DeleteByIDAndTeam(c.Request.Context(), providerID, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete provider"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"})
return
}
@@ -274,46 +265,46 @@ func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) {
func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
ctx := c.Request.Context()
var name, providerType, endpoint string
var apiKeyEnc, keyNonce []byte
var keyScope string
var headersJSON []byte
err := database.DB.QueryRow(database.Q(`
SELECT name, provider, endpoint, api_key_enc, key_nonce, key_scope, headers
FROM provider_configs
WHERE id = $1 AND scope = 'team' AND owner_id = $2 AND is_active = true
`), providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKeyEnc, &keyNonce, &keyScope, &headersJSON)
if err != nil {
cfg, err := h.stores.Providers.GetByID(ctx, providerID)
if err != nil || cfg.Scope != models.ScopeTeam || !cfg.IsActive {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
if cfg.OwnerID == nil || *cfg.OwnerID != teamID {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
provider, err := providers.Get(providerType)
provider, err := providers.Get(cfg.Provider)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported provider"})
return
}
key := ""
if len(apiKeyEnc) > 0 {
if cfg.HasKey() {
if h.vault != nil {
var err error
key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, "")
key, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt API key"})
return
}
} else {
key = string(apiKeyEnc)
key = string(cfg.APIKeyEnc)
}
}
var customHeaders map[string]string
_ = json.Unmarshal(headersJSON, &customHeaders)
if cfg.Headers != nil {
b, _ := json.Marshal(cfg.Headers)
_ = json.Unmarshal(b, &customHeaders)
}
modelList, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
Endpoint: endpoint,
modelList, err := provider.ListModels(ctx, providers.ProviderConfig{
Endpoint: cfg.Endpoint,
APIKey: key,
CustomHeaders: customHeaders,
})
@@ -335,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": name})
c.JSON(http.StatusOK, gin.H{"models": out, "provider": cfg.Name})
}
// parseJSONBConfig parses a JSONB text string into a map.
@@ -351,32 +342,30 @@ func parseJSONBConfig(raw string) map[string]interface{} {
}
// isTeamProvidersAllowed checks if team providers are enabled.
func isTeamProvidersAllowed(teamID string) bool {
if database.DB == nil {
func isTeamProvidersAllowed(stores store.Stores, teamID string) bool {
if stores.GlobalConfig == nil {
return false
}
var globalVal string
err := database.DB.QueryRow(`
SELECT value FROM global_settings WHERE key = 'allow_team_providers'
`).Scan(&globalVal)
ctx := context.Background()
// Check global setting
globalVal, err := stores.GlobalConfig.GetString(ctx, "allow_team_providers")
if err == nil && globalVal == "false" {
return false
}
var settingsJSON []byte
err = database.DB.QueryRow(database.Q(`SELECT settings FROM teams WHERE id = $1`), teamID).Scan(&settingsJSON)
// Check team-level setting
team, err := stores.Teams.GetByID(ctx, teamID)
if err != nil {
return true
return true // fail open
}
var settings map[string]interface{}
if err := json.Unmarshal(settingsJSON, &settings); err != nil {
return true
}
if v, ok := settings["allow_team_providers"]; ok {
if b, ok := v.(bool); ok {
return b
if team.Settings != nil {
if v, ok := team.Settings["allow_team_providers"]; ok {
if bVal, ok := v.(bool); ok {
return bVal
}
}
}

View File

@@ -1,14 +1,13 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -152,68 +151,44 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
return
}
// Build dynamic update
sets := []string{}
args := []interface{}{}
argN := 1
addArg := func(col string, val interface{}) {
if database.IsSQLite() {
sets = append(sets, col+" = ?")
} else {
sets = append(sets, col+" = $"+strconv.Itoa(argN))
}
args = append(args, val)
argN++
}
// Build fields map for store.Update (simple scalar fields)
fields := map[string]interface{}{}
if req.Name != nil {
addArg("name", *req.Name)
fields["name"] = *req.Name
}
if req.Description != nil {
addArg("description", *req.Description)
fields["description"] = *req.Description
}
if req.IsActive != nil {
addArg("is_active", *req.IsActive)
}
if req.Settings != nil {
if database.IsSQLite() {
// SQLite: json_patch for merge
sets = append(sets, "settings = json_patch(COALESCE(settings, '{}'), ?)")
} else {
sets = append(sets, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
}
args = append(args, *req.Settings)
argN++
fields["is_active"] = *req.IsActive
}
if len(sets) == 0 {
hasFields := len(fields) > 0
hasSettings := req.Settings != nil
if !hasFields && !hasSettings {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
var whereClause string
if database.IsSQLite() {
whereClause = " WHERE id = ?"
} else {
whereClause = " WHERE id = $" + strconv.Itoa(argN)
}
args = append(args, teamID)
ctx := c.Request.Context()
query := "UPDATE teams SET " + strings.Join(sets, ", ") + whereClause
res, err := database.DB.Exec(query, args...)
if err != nil {
if database.IsUniqueViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
if hasFields {
if err := h.stores.Teams.Update(ctx, teamID, fields); err != nil {
if database.IsUniqueViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
if hasSettings {
if err := h.stores.Teams.MergeSettings(ctx, teamID, *req.Settings); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "settings update failed"})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
@@ -266,6 +241,7 @@ func (h *TeamHandler) ListMembers(c *gin.Context) {
func (h *TeamHandler) AddMember(c *gin.Context) {
teamID := getTeamID(c)
ctx := c.Request.Context()
var req addMemberRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -274,25 +250,20 @@ func (h *TeamHandler) AddMember(c *gin.Context) {
}
// Verify team exists
var exists bool
database.DB.QueryRow(database.Q(`SELECT EXISTS(SELECT 1 FROM teams WHERE id = $1)`), teamID).Scan(&exists)
exists, _ := h.stores.Teams.Exists(ctx, teamID)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
// Verify user exists
database.DB.QueryRow(database.Q(`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`), req.UserID).Scan(&exists)
if !exists {
userExists, _ := h.stores.Users.Exists(ctx, req.UserID)
if !userExists {
c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
return
}
id, err := database.InsertReturningID(`
INSERT INTO team_members (team_id, user_id, role)
VALUES ($1, $2, $3)
RETURNING id
`, teamID, req.UserID, req.Role)
id, err := h.stores.Teams.AddMemberReturningID(ctx, teamID, req.UserID, req.Role)
if err != nil {
if database.IsUniqueViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": "user is already a member"})
@@ -320,14 +291,12 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
return
}
res, err := database.DB.Exec(database.Q(`
UPDATE team_members SET role = $1 WHERE id = $2 AND team_id = $3
`), req.Role, memberID, teamID)
n, err := h.stores.Teams.UpdateMemberRoleByID(c.Request.Context(), memberID, teamID, req.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
return
}
@@ -344,12 +313,12 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) {
teamID := getTeamID(c)
memberID := c.Param("memberId")
res, err := database.DB.Exec(database.Q(`DELETE FROM team_members WHERE id = $1 AND team_id = $2`), memberID, teamID)
n, err := h.stores.Teams.DeleteMemberByID(c.Request.Context(), memberID, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
return
}
@@ -384,6 +353,7 @@ func (h *TeamHandler) MyTeams(c *gin.Context) {
// GET /api/v1/teams/:teamId/models
func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
teamID := getTeamID(c)
ctx := c.Request.Context()
type availableModel struct {
ID string `json:"id"`
@@ -395,79 +365,62 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
Source string `json:"source"`
}
models := make([]availableModel, 0)
result := make([]availableModel, 0)
// ── 1. Global admin models (synced in model_catalog) ──
rows, err := database.DB.Query(database.Q(`
SELECT mc.id, mc.model_id, mc.display_name, mc.visibility,
ac.provider, ac.name as provider_name
FROM model_catalog mc
JOIN provider_configs ac ON mc.provider_config_id = ac.id
WHERE mc.visibility IN ('enabled', 'team')
AND ac.is_active = true AND ac.scope = 'global'
ORDER BY ac.name, mc.model_id
`))
catalogModels, err := h.stores.Catalog.ListTeamAvailable(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
defer rows.Close()
for rows.Next() {
var m availableModel
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility,
&m.Provider, &m.ProviderName); err != nil {
continue
}
m.Source = "global"
models = append(models, m)
for _, cm := range catalogModels {
result = append(result, availableModel{
ID: cm.ID,
ModelID: cm.ModelID,
DisplayName: cm.DisplayName,
Visibility: cm.Visibility,
Provider: cm.Provider,
ProviderName: cm.ProviderName,
Source: "global",
})
}
// ── 2. Team provider models (live query) ──
teamRows, err := database.DB.Query(database.Q(`
SELECT id, name, provider, endpoint, api_key_enc, headers
FROM provider_configs
WHERE scope = 'team' AND owner_id = $1 AND is_active = true
`), teamID)
teamConfigs, err := h.stores.Providers.ListForTeam(ctx, teamID)
if err == nil {
defer teamRows.Close()
for teamRows.Next() {
var cfgID, name, providerID, endpoint string
var apiKey *string
var headersJSON []byte
if err := teamRows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey, &headersJSON); err != nil {
continue
}
provider, pErr := providers.Get(providerID)
for _, cfg := range teamConfigs {
provider, pErr := providers.Get(cfg.Provider)
if pErr != nil {
continue
}
key := ""
if apiKey != nil {
key = *apiKey
if cfg.HasKey() {
key = string(cfg.APIKeyEnc)
}
var customHeaders map[string]string
_ = json.Unmarshal(headersJSON, &customHeaders)
if cfg.Headers != nil {
b, _ := json.Marshal(cfg.Headers)
_ = json.Unmarshal(b, &customHeaders)
}
provModels, lErr := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
Endpoint: endpoint,
provModels, lErr := provider.ListModels(ctx, providers.ProviderConfig{
Endpoint: cfg.Endpoint,
APIKey: key,
CustomHeaders: customHeaders,
})
if lErr != nil {
log.Printf("[models] team provider %q list failed: %v", name, lErr)
log.Printf("[models] team provider %q list failed: %v", cfg.Name, lErr)
continue
}
for _, pm := range provModels {
models = append(models, availableModel{
result = append(result, availableModel{
ID: pm.ID,
ModelID: pm.ID,
Provider: providerID,
ProviderName: name,
Provider: cfg.Provider,
ProviderName: cfg.Name,
Visibility: "enabled",
Source: "team",
})
@@ -475,7 +428,7 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"models": models})
c.JSON(http.StatusOK, gin.H{"models": result})
}
// ── Helpers ─────────────────────────────────
@@ -488,68 +441,25 @@ func getTeamID(c *gin.Context) string {
return c.Param("id")
}
// IsTeamAdmin checks if a user is an admin of the given team.
func IsTeamAdmin(userID, teamID string) bool {
var role string
err := database.DB.QueryRow(database.Q(`
SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2
`), teamID, userID).Scan(&role)
return err == nil && role == "admin"
}
// IsTeamMember checks if a user belongs to the given team (any role).
func IsTeamMember(userID, teamID string) bool {
var exists bool
database.DB.QueryRow(database.Q(`
SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)
`), teamID, userID).Scan(&exists)
return exists
}
// enforcePrivateProviderPolicy checks if a user belongs to any team that
// requires private providers, and if so, verifies the resolved config is
// marked as private. Returns nil if allowed, error if blocked.
func enforcePrivateProviderPolicy(userID, configID string) error {
func enforcePrivateProviderPolicy(ctx context.Context, stores store.Stores, userID, configID string) error {
if configID == "" {
return nil
}
// Check if user belongs to any team with require_private_providers policy
var requiresPrivate bool
var query string
if database.IsSQLite() {
query = `
SELECT EXISTS(
SELECT 1 FROM team_members tm
JOIN teams t ON t.id = tm.team_id
WHERE tm.user_id = ?
AND t.is_active = 1
AND json_extract(t.settings, '$.require_private_providers') = 'true'
)`
} else {
query = `
SELECT EXISTS(
SELECT 1 FROM team_members tm
JOIN teams t ON t.id = tm.team_id
WHERE tm.user_id = $1
AND t.is_active = true
AND t.settings->>'require_private_providers' = 'true'
)`
}
err := database.DB.QueryRow(query, userID).Scan(&requiresPrivate)
requiresPrivate, err := stores.Teams.HasPrivateProviderRequirement(ctx, userID)
if err != nil || !requiresPrivate {
return nil
}
// User is in a restricted team — verify the config is private
var isPrivate bool
err = database.DB.QueryRow(database.Q(`
SELECT COALESCE(is_private, false) FROM provider_configs WHERE id = $1
`), configID).Scan(&isPrivate)
cfg, err := stores.Providers.GetByID(ctx, configID)
if err != nil {
return nil // config lookup failed, allow (fail open)
}
if !isPrivate {
if !cfg.IsPrivate {
return fmt.Errorf("your team requires private providers — this provider sends data externally")
}
return nil
@@ -561,84 +471,31 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
teamID := c.Param("teamId")
page, perPage, offset := parsePagination(c)
// Build filter clauses — always scoped to team members.
// Use ? placeholders and convert for Postgres if needed.
clauses := []string{"al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = ?)"}
args := []interface{}{teamID}
opts := store.AuditListOptions{
ListOptions: store.ListOptions{
Limit: perPage,
Offset: offset,
},
TeamID: teamID,
}
if action := c.Query("action"); action != "" {
clauses = append(clauses, "al.action = ?")
args = append(args, action)
opts.Action = action
}
if actorID := c.Query("actor_id"); actorID != "" {
clauses = append(clauses, "al.actor_id = ?")
args = append(args, actorID)
opts.ActorID = actorID
}
if rt := c.Query("resource_type"); rt != "" {
clauses = append(clauses, "al.resource_type = ?")
args = append(args, rt)
opts.ResourceType = rt
}
where := "WHERE " + strings.Join(clauses, " AND ")
// For Postgres, convert ? to $N
if database.IsPostgres() {
where = convertPlaceholders(where)
}
// Count
var total int
countArgs := make([]interface{}, len(args))
copy(countArgs, args)
err := database.DB.QueryRow(`SELECT COUNT(*) FROM audit_log al `+where, countArgs...).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"})
return
}
// Query with actor name join
limitOffset := fmt.Sprintf("LIMIT %d OFFSET %d", perPage, offset)
query := `
SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name,
al.action, al.resource_type, al.resource_id,
COALESCE(al.metadata, '{}'), al.ip_address, al.created_at
FROM audit_log al
LEFT JOIN users u ON al.actor_id = u.id
` + where + `
ORDER BY al.created_at DESC
` + limitOffset
rows, err := database.DB.Query(query, args...)
entries, total, err := h.stores.Audit.List(c.Request.Context(), opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
defer rows.Close()
type entry struct {
ID string `json:"id"`
ActorID *string `json:"actor_id"`
ActorName *string `json:"actor_name"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID *string `json:"resource_id"`
Metadata string `json:"metadata"`
IPAddress *string `json:"ip_address"`
CreatedAt time.Time `json:"created_at"`
}
entries := make([]entry, 0)
for rows.Next() {
var e entry
var actorName sql.NullString
if err := rows.Scan(&e.ID, &e.ActorID, &actorName, &e.Action,
&e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, database.ST(&e.CreatedAt)); err != nil {
continue
}
if actorName.Valid {
e.ActorName = &actorName.String
}
entries = append(entries, e)
if entries == nil {
entries = []models.AuditEntry{}
}
c.JSON(http.StatusOK, gin.H{
@@ -652,39 +509,11 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) {
teamID := c.Param("teamId")
rows, err := database.DB.Query(database.Q(`
SELECT DISTINCT al.action
FROM audit_log al
WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1)
ORDER BY al.action ASC
`), teamID)
actions, err := h.stores.Teams.ListTeamAuditActions(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"actions": []string{}})
return
}
defer rows.Close()
actions := make([]string, 0)
for rows.Next() {
var a string
if rows.Scan(&a) == nil {
actions = append(actions, a)
}
}
c.JSON(http.StatusOK, gin.H{"actions": actions})
}
// convertPlaceholders converts ? placeholders to $1, $2, etc. for Postgres.
func convertPlaceholders(q string) string {
n := 1
var result strings.Builder
for _, ch := range q {
if ch == '?' {
result.WriteString(fmt.Sprintf("$%d", n))
n++
} else {
result.WriteRune(ch)
}
}
return result.String()
}

View File

@@ -1,5 +1,9 @@
package handlers
// workflow_assignments.go — Assignment queue for human review stages.
//
// v0.29.0: Raw SQL replaced with WorkflowStore + ChannelStore methods.
import (
"encoding/json"
"net/http"
@@ -7,104 +11,51 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Workflow Assignment Handler ─────────────
// Manages the assignment queue for human review stages.
type WorkflowAssignmentHandler struct {
hub *events.Hub
stores store.Stores
hub *events.Hub
}
func NewWorkflowAssignmentHandler(hub ...*events.Hub) *WorkflowAssignmentHandler {
h := &WorkflowAssignmentHandler{}
func NewWorkflowAssignmentHandler(stores store.Stores, hub ...*events.Hub) *WorkflowAssignmentHandler {
h := &WorkflowAssignmentHandler{stores: stores}
if len(hub) > 0 {
h.hub = hub[0]
}
return h
}
type assignmentRow struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
Stage int `json:"stage"`
TeamID string `json:"team_id"`
AssignedTo *string `json:"assigned_to"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ClaimedAt *time.Time `json:"claimed_at"`
CompletedAt *time.Time `json:"completed_at"`
}
// ListForTeam returns unassigned + claimed assignments for a team.
// GET /api/v1/teams/:teamId/assignments
func (h *WorkflowAssignmentHandler) ListForTeam(c *gin.Context) {
teamID := c.Param("teamId")
status := c.DefaultQuery("status", "unassigned")
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT id, channel_id, stage, team_id, assigned_to, status,
created_at, claimed_at, completed_at
FROM workflow_assignments
WHERE team_id = $1 AND status = $2
ORDER BY created_at ASC
`), teamID, status)
result, err := h.stores.Workflows.ListAssignmentsForTeam(c.Request.Context(), teamID, status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
return
}
defer rows.Close()
var result []assignmentRow
for rows.Next() {
var a assignmentRow
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
&a.AssignedTo, &a.Status, database.ST(&a.CreatedAt), database.SNT(&a.ClaimedAt), database.SNT(&a.CompletedAt)); err != nil {
continue
}
result = append(result, a)
}
if result == nil {
result = []assignmentRow{}
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
// ListForUser returns assignments claimed by the current user plus
// ListMine returns assignments claimed by the current user plus
// unassigned assignments for teams the user belongs to.
// GET /api/v1/workflow-assignments/mine
func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) {
userID := c.GetString("user_id")
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT DISTINCT wa.id, wa.channel_id, wa.stage, wa.team_id, wa.assigned_to, wa.status,
wa.created_at, wa.claimed_at, wa.completed_at
FROM workflow_assignments wa
LEFT JOIN team_members tm ON tm.team_id = wa.team_id AND tm.user_id = $1
WHERE (wa.assigned_to = $2 AND wa.status = 'claimed')
OR (wa.status = 'unassigned' AND tm.user_id IS NOT NULL)
ORDER BY wa.created_at DESC
`), userID, userID)
result, err := h.stores.Workflows.ListAssignmentsMine(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
return
}
defer rows.Close()
var result []assignmentRow
for rows.Next() {
var a assignmentRow
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
&a.AssignedTo, &a.Status, database.ST(&a.CreatedAt), database.SNT(&a.ClaimedAt), database.SNT(&a.CompletedAt)); err != nil {
continue
}
result = append(result, a)
}
if result == nil {
result = []assignmentRow{}
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
@@ -115,30 +66,20 @@ func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) {
userID := c.GetString("user_id")
now := time.Now().UTC()
res, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE workflow_assignments
SET assigned_to = $1, status = 'claimed', claimed_at = $2
WHERE id = $3 AND status = 'unassigned'
`), userID, now, assignmentID)
n, err := h.stores.Workflows.ClaimAssignment(c.Request.Context(), assignmentID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to claim assignment"})
return
}
n, _ := res.RowsAffected()
if n == 0 {
c.JSON(http.StatusConflict, gin.H{"error": "assignment already claimed or not found"})
return
}
// Look up channel for this assignment (needed for WS delivery + notification)
var channelID string
_ = database.DB.QueryRowContext(c.Request.Context(),
database.Q(`SELECT channel_id FROM workflow_assignments WHERE id = $1`),
assignmentID).Scan(&channelID)
// Look up channel for WS delivery + notification
channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID)
// Emit workflow.claimed WS event to all user participants in the channel.
// Uses SendToUser (not room-scoped Bus.Publish) because room subscriptions
// are not yet wired on the client side. See websocket.md § Room Model.
// Emit workflow.claimed WS event to all user participants in the channel
if h.hub != nil && channelID != "" {
payload, _ := json.Marshal(map[string]any{
"assignment_id": assignmentID,
@@ -150,22 +91,13 @@ func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) {
Payload: payload,
Ts: now.UnixMilli(),
}
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err == nil {
defer rows.Close()
for rows.Next() {
var uid string
if rows.Scan(&uid) == nil {
h.hub.SendToUser(uid, evt)
}
}
pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "")
for _, uid := range pids {
h.hub.SendToUser(uid, evt)
}
}
// Persist notification for bell/inbox (v0.28.2)
// Persist notification for bell/inbox
if svc := notifications.Default(); svc != nil && channelID != "" {
notifications.NotifyWorkflowClaimed(svc, userID, assignmentID, channelID)
}
@@ -177,18 +109,12 @@ func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) {
// POST /api/v1/workflow-assignments/:id/complete
func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) {
assignmentID := c.Param("id")
now := time.Now().UTC()
res, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE workflow_assignments
SET status = 'completed', completed_at = $1
WHERE id = $2 AND status = 'claimed'
`), now, assignmentID)
n, err := h.stores.Workflows.CompleteAssignment(c.Request.Context(), assignmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete assignment"})
return
}
n, _ := res.RowsAffected()
if n == 0 {
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"})
return

View File

@@ -4,11 +4,9 @@ import (
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -77,20 +75,15 @@ func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) {
}
// Set workflow columns
allowAnonVal := interface{}(true)
if database.CurrentDialect == database.DialectSQLite {
allowAnonVal = 1
}
_, err = database.DB.ExecContext(ctx, database.Q(`
UPDATE channels
SET workflow_id = $1, workflow_version = $2, current_stage = 0,
stage_data = '{}', workflow_status = 'active',
last_activity_at = $3, allow_anonymous = $4, ai_mode = 'auto'
WHERE id = $5
`), wf.ID, ver.VersionNumber, time.Now().UTC(), allowAnonVal, ch.ID)
err = h.stores.Channels.SetWorkflowInstance(ctx, ch.ID, wf.ID, ver.VersionNumber, []byte("{}"), "active")
if err != nil {
log.Printf("Failed to set workflow columns: %v", err)
}
// Enable anonymous access + auto AI mode for visitor entry
_ = h.stores.Channels.Update(ctx, ch.ID, map[string]interface{}{
"allow_anonymous": true,
"ai_mode": "auto",
})
// Create anonymous session
sessionToken := store.NewID()

View File

@@ -79,7 +79,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness {
protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
// Assignments
wfAssignH := NewWorkflowAssignmentHandler()
wfAssignH := NewWorkflowAssignmentHandler(stores)
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
@@ -93,7 +93,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness {
// Team-scoped assignment listing (mirrors main.go teamScoped)
teamScoped := protected.Group("/teams/:teamId")
teamAssignH := NewWorkflowAssignmentHandler()
teamAssignH := NewWorkflowAssignmentHandler(stores)
teamScoped.GET("/assignments", teamAssignH.ListForTeam)
// Visitor entry

View File

@@ -3,13 +3,13 @@ package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
@@ -77,24 +77,14 @@ func (h *WorkflowInstanceHandler) Start(c *gin.Context) {
// Set workflow-specific columns (not part of base Channel.Create)
allowAnon := wf.EntryMode == "public_link"
var allowAnonVal interface{} = allowAnon
if database.CurrentDialect == database.DialectSQLite {
if allowAnon {
allowAnonVal = 1
} else {
allowAnonVal = 0
}
}
_, err = database.DB.ExecContext(ctx, database.Q(`
UPDATE channels
SET workflow_id = $1, workflow_version = $2, current_stage = 0,
stage_data = '{}', workflow_status = 'active',
last_activity_at = $3, allow_anonymous = $4, ai_mode = 'auto'
WHERE id = $5
`), wfID, ver.VersionNumber, time.Now().UTC(), allowAnonVal, ch.ID)
err = h.stores.Channels.SetWorkflowInstance(ctx, ch.ID, wfID, ver.VersionNumber, []byte("{}"), "active")
if err != nil {
log.Printf("Failed to set workflow columns on channel %s: %v", ch.ID, err)
}
_ = h.stores.Channels.Update(ctx, ch.ID, map[string]interface{}{
"allow_anonymous": allowAnon,
"ai_mode": "auto",
})
// Add caller as channel owner
_ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
@@ -126,34 +116,15 @@ func (h *WorkflowInstanceHandler) Start(c *gin.Context) {
// ── Status ──────────────────────────────────
// WorkflowChannelStatus holds the runtime state of a workflow instance.
type WorkflowChannelStatus struct {
WorkflowID *string `json:"workflow_id"`
WorkflowVersion *int `json:"workflow_version"`
CurrentStage int `json:"current_stage"`
StageData json.RawMessage `json:"stage_data"`
Status string `json:"status"`
LastActivityAt *string `json:"last_activity_at"`
}
// GetStatus returns the workflow state for a channel.
// GET /api/v1/channels/:id/workflow/status
func (h *WorkflowInstanceHandler) GetStatus(c *gin.Context) {
channelID := c.Param("id")
var ws WorkflowChannelStatus
var stageData []byte
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT workflow_id, workflow_version, current_stage,
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
last_activity_at
FROM channels WHERE id = $1 AND type = 'workflow'
`), channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
if err != nil {
ws, err := h.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID)
if err != nil || ws == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
return
}
ws.StageData = stageData
c.JSON(http.StatusOK, ws)
}
@@ -186,17 +157,12 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
}
_ = c.ShouldBindJSON(&body)
mergedData := tools.MergeWorkflowStageData(ctx, channelID, body.Data)
mergedData := tools.MergeWorkflowStageData(ctx, h.stores.Channels, channelID, body.Data)
nextStage := currentStage + 1
if nextStage >= len(stages) {
// Workflow complete
_, err = database.DB.ExecContext(ctx, database.Q(`
UPDATE channels
SET current_stage = $1, workflow_status = 'completed',
stage_data = $2, last_activity_at = $3, ai_mode = 'off'
WHERE id = $4
`), nextStage, mergedData, time.Now().UTC(), channelID)
err = h.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete workflow"})
return
@@ -216,11 +182,7 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
}
// Advance to next stage
_, err = database.DB.ExecContext(ctx, database.Q(`
UPDATE channels
SET current_stage = $1, stage_data = $2, last_activity_at = $3
WHERE id = $4
`), nextStage, mergedData, time.Now().UTC(), channelID)
err = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, json.RawMessage(mergedData))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to advance stage"})
return
@@ -245,7 +207,7 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
// v0.27.0: Assignment + round-robin + WS notifications
if nextStageDef.AssignmentTeamID != nil {
assignmentID := tools.CreateWorkflowAssignment(ctx, channelID, nextStage, *nextStageDef.AssignmentTeamID)
assignmentID := tools.CreateWorkflowAssignment(ctx, h.stores, channelID, nextStage, *nextStageDef.AssignmentTeamID)
// Round-robin auto-assignment if configured
assignedTo := h.tryRoundRobin(ctx, nextStageDef, assignmentID)
@@ -298,9 +260,7 @@ func (h *WorkflowInstanceHandler) Reject(c *gin.Context) {
}
prevStage := currentStage - 1
_, err = database.DB.ExecContext(ctx, database.Q(`
UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3
`), prevStage, time.Now().UTC(), channelID)
err = h.stores.Channels.RejectWorkflowToStage(ctx, channelID, prevStage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject stage"})
return
@@ -327,15 +287,14 @@ func (h *WorkflowInstanceHandler) Reject(c *gin.Context) {
// ── Helpers ─────────────────────────────────
func (h *WorkflowInstanceHandler) readWorkflowState(ctx context.Context, channelID string) (workflowID string, currentStage int, status string, err error) {
var wfID *string
err = database.DB.QueryRowContext(ctx, database.Q(`
SELECT workflow_id, COALESCE(current_stage, 0), COALESCE(workflow_status, 'active')
FROM channels WHERE id = $1 AND type = 'workflow'
`), channelID).Scan(&wfID, &currentStage, &status)
if wfID != nil {
workflowID = *wfID
ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID)
if err != nil || ws == nil {
return "", 0, "", fmt.Errorf("workflow channel not found")
}
return
if ws.WorkflowID != nil {
workflowID = *ws.WorkflowID
}
return workflowID, ws.CurrentStage, ws.Status, nil
}
// emitWorkflowEvent pushes a workflow event to all user participants in the channel.
@@ -353,20 +312,13 @@ func (h *WorkflowInstanceHandler) emitWorkflowEvent(label, channelID string, dat
}
// Send to all user participants in the channel
rows, err := database.DB.Query(database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
pids, err := h.stores.Channels.ListUserParticipantIDs(context.Background(), channelID, "")
if err != nil {
log.Printf("[ws] %s: failed to query participants for channel %s: %v", label, channelID[:min(8, len(channelID))], err)
return
}
defer rows.Close()
for rows.Next() {
var uid string
if rows.Scan(&uid) == nil {
h.hub.SendToUser(uid, evt)
}
for _, uid := range pids {
h.hub.SendToUser(uid, evt)
}
}
@@ -396,52 +348,15 @@ func (h *WorkflowInstanceHandler) tryRoundRobin(ctx context.Context, stage model
return ""
}
// Get team members
members, err := h.stores.Teams.ListMembers(ctx, *stage.AssignmentTeamID)
if err != nil || len(members) == 0 {
return ""
}
// Find the least-recently-assigned member.
// Query: for each member, find their most recent claimed_at in workflow_assignments.
// Pick the member with the oldest (or null) claimed_at.
var bestUserID string
bestUserID = members[0].UserID // fallback to first member
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01T00:00:00Z') as last_claim
FROM team_members m
LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1
WHERE m.team_id = $2
GROUP BY m.user_id
ORDER BY last_claim ASC
LIMIT 1
`), *stage.AssignmentTeamID, *stage.AssignmentTeamID)
if err == nil {
defer rows.Close()
if rows.Next() {
var uid string
var lastClaim string // COALESCE returns TEXT on both dialects
if err := rows.Scan(&uid, &lastClaim); err == nil {
bestUserID = uid
}
}
}
// Claim the assignment for this user
now := time.Now().UTC()
_, err = database.DB.ExecContext(ctx, database.Q(`
UPDATE workflow_assignments
SET assigned_to = $1, status = 'claimed', claimed_at = $2
WHERE id = $3 AND status = 'unassigned'
`), bestUserID, now, assignmentID)
assignedTo, err := h.stores.Workflows.TryRoundRobin(ctx, *stage.AssignmentTeamID, assignmentID)
if err != nil {
log.Printf("[workflow] round-robin: failed to auto-assign %s to %s: %v", assignmentID, bestUserID, err)
log.Printf("[workflow] round-robin: failed to auto-assign %s: %v", assignmentID, err)
return ""
}
log.Printf("[workflow] round-robin: auto-assigned %s to user %s", assignmentID, bestUserID)
return bestUserID
if assignedTo != "" {
log.Printf("[workflow] round-robin: auto-assigned %s to user %s", assignmentID, assignedTo)
}
return assignedTo
}
// notifyAssignment sends notifications to team members about a new workflow assignment.

View File

@@ -319,7 +319,7 @@ func setupWorkflowHarness(t *testing.T) *workflowHarness {
protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
// Channels (needed for workflow instance creation)
channels := NewChannelHandler()
channels := NewChannelHandler(stores)
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
protected.GET("/channels/:id", channels.GetChannel)