Changeset 0.9.0 (#50)

This commit is contained in:
2026-02-23 01:57:28 +00:00
parent 15be26c516
commit 8264aa6016
94 changed files with 9812 additions and 8574 deletions

View File

@@ -1,12 +1,12 @@
# Chat Switchboard - Server Environment Variables
# Chat Switchboard v0.9 - Server Environment Variables
# Copy this file to .env and fill in the values
# Server settings
# ── Server ───────────────────────────────────
PORT=8080
ENVIRONMENT=development
DEBUG=true
BASE_PATH= # e.g. /chat for path-based routing
# Database settings (PostgreSQL)
# ── Database (PostgreSQL) ────────────────────
DB_HOST=localhost
DB_PORT=5432
DB_USER=chat_switchboard
@@ -15,30 +15,25 @@ DB_NAME=chat_switchboard
DB_SSL_MODE=disable
DB_MAX_CONNS=25
# JWT settings
# ── Auth ─────────────────────────────────────
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRATION=24h
JWT_ISSUER=chat-switchboard
# CORS settings (comma-separated for multiple origins)
# ── Bootstrap Admin ──────────────────────────
# Creates or updates admin account on every startup.
# Set via K8s secret or env. Leave blank to skip.
SWITCHBOARD_ADMIN_USERNAME=
SWITCHBOARD_ADMIN_PASSWORD=
# ── CORS ─────────────────────────────────────
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS,PATCH
CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With,Accept,Origin
# Rate limiting
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW=1m
# ── Banner (optional) ───────────────────────
# Environment classification banner.
# BANNER_TEXT=DEVELOPMENT
# BANNER_COLOR=#007a33
# BANNER_POSITION=top
# Logging
# ── Logging ──────────────────────────────────
LOG_LEVEL=info
# Optional: Redis configuration (for WebSocket pub/sub and session cache)
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_DB=0
# REDIS_PASSWORD=
# Optional: External services
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# GOOGLE_API_KEY=

View File

@@ -29,6 +29,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /bin/switchboard /usr/local/bin/switchboard
COPY --from=builder /app/database/migrations /app/database/migrations
WORKDIR /app
EXPOSE 8080

View File

@@ -1,6 +1,10 @@
package providers
package capabilities
import "testing"
import (
"testing"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
func TestLookupKnownModel_ExactMatch(t *testing.T) {
caps, ok := LookupKnownModel("gpt-4o")
@@ -79,8 +83,7 @@ func TestInferCapabilities_Reasoning(t *testing.T) {
}
func TestResolveMaxOutput_FromCaps(t *testing.T) {
// Explicit value in caps takes priority
caps := ModelCapabilities{MaxOutputTokens: 32000}
caps := models.ModelCapabilities{MaxOutputTokens: 32000}
got := ResolveMaxOutput("whatever-model", caps)
if got != 32000 {
t.Errorf("got %d, want 32000", got)
@@ -88,8 +91,7 @@ func TestResolveMaxOutput_FromCaps(t *testing.T) {
}
func TestResolveMaxOutput_FromKnownTable(t *testing.T) {
// No value in caps, falls to known table
caps := ModelCapabilities{}
caps := models.ModelCapabilities{}
got := ResolveMaxOutput("claude-opus-4-20250514", caps)
if got != 32000 {
t.Errorf("got %d, want 32000", got)
@@ -97,25 +99,21 @@ func TestResolveMaxOutput_FromKnownTable(t *testing.T) {
}
func TestResolveMaxOutput_FromContext(t *testing.T) {
// Unknown model but has context window
caps := ModelCapabilities{MaxContext: 32768}
caps := models.ModelCapabilities{MaxContext: 32768}
got := ResolveMaxOutput("unknown-model-abc", caps)
// 32768 / 8 = 4096
if got != 4096 {
t.Errorf("got %d, want 4096 (derived from context/8)", got)
}
}
func TestResolveMaxOutput_ContextClamp(t *testing.T) {
// Very large context → clamp derived output to 16384
caps := ModelCapabilities{MaxContext: 1048576}
caps := models.ModelCapabilities{MaxContext: 1048576}
got := ResolveMaxOutput("unknown-large-model", caps)
if got != 16384 {
t.Errorf("got %d, want 16384 (clamped)", got)
}
// Very small context → clamp derived output to 2048
caps = ModelCapabilities{MaxContext: 4096}
caps = models.ModelCapabilities{MaxContext: 4096}
got = ResolveMaxOutput("unknown-tiny-model", caps)
if got != 2048 {
t.Errorf("got %d, want 2048 (clamped)", got)
@@ -123,22 +121,21 @@ func TestResolveMaxOutput_ContextClamp(t *testing.T) {
}
func TestResolveMaxOutput_LastResort(t *testing.T) {
// Totally unknown, no context
caps := ModelCapabilities{}
caps := models.ModelCapabilities{}
got := ResolveMaxOutput("mystery-model", caps)
if got != 4096 {
t.Errorf("got %d, want 4096 (last resort)", got)
}
}
func TestMergeCapabilities(t *testing.T) {
func TestResolveIntrinsic(t *testing.T) {
// Provider reports tool_calling and max_output — these should be authoritative.
// Known table for claude-sonnet-4 has vision, thinking, etc — should fill gaps.
providerCaps := ModelCapabilities{
providerCaps := models.ModelCapabilities{
ToolCalling: true,
MaxOutputTokens: 16384,
}
merged := MergeCapabilities(providerCaps, "claude-sonnet-4-20250514")
merged := ResolveIntrinsic("claude-sonnet-4-20250514", &providerCaps)
if !merged.ToolCalling {
t.Error("tool_calling should be preserved from provider")
@@ -146,57 +143,52 @@ func TestMergeCapabilities(t *testing.T) {
if merged.MaxOutputTokens != 16384 {
t.Errorf("max_output should be 16384 from provider, got %d", merged.MaxOutputTokens)
}
// Vision should be filled from known table (claude-sonnet-4 has it)
if !merged.Vision {
t.Error("vision should be filled from known table")
}
// MaxContext should be filled from known table
if merged.MaxContext == 0 {
t.Error("max_context should be filled from known table")
}
}
func TestMergeCapabilities_ProviderFalseWins(t *testing.T) {
// Provider explicitly reports vision:false — should NOT be overridden by known table
providerCaps := ModelCapabilities{
func TestResolveIntrinsic_ProviderFalseWins(t *testing.T) {
providerCaps := models.ModelCapabilities{
ToolCalling: true,
Vision: false, // provider says no vision
Vision: false,
MaxOutputTokens: 8192,
MaxContext: 65536,
}
// Even though gpt-4o has vision in known table, provider caps are authoritative
// Because HasProviderData() is true, the caller passes these as authoritative
merged := MergeCapabilities(providerCaps, "some-unknown-model")
merged := ResolveIntrinsic("some-unknown-model", &providerCaps)
if merged.Vision {
t.Error("vision should remain false — provider is authoritative")
}
}
func TestMergeCapabilities_EmptyProvider(t *testing.T) {
// Empty provider caps — should fall through entirely to known table
merged := MergeCapabilities(ModelCapabilities{}, "gpt-4o")
func TestResolveIntrinsic_NilProvider(t *testing.T) {
// Nil provider caps — should fall through entirely to known table
merged := ResolveIntrinsic("gpt-4o", nil)
if !merged.ToolCalling {
t.Error("should get tool_calling from known table when provider is empty")
t.Error("should get tool_calling from known table when provider is nil")
}
if !merged.Vision {
t.Error("should get vision from known table when provider is empty")
t.Error("should get vision from known table when provider is nil")
}
}
func TestHasProviderData(t *testing.T) {
empty := ModelCapabilities{}
empty := models.ModelCapabilities{}
if empty.HasProviderData() {
t.Error("empty caps should not have provider data")
}
withTool := ModelCapabilities{ToolCalling: true}
withTool := models.ModelCapabilities{ToolCalling: true}
if !withTool.HasProviderData() {
t.Error("caps with tool_calling should have provider data")
}
withContext := ModelCapabilities{MaxContext: 128000}
withContext := models.ModelCapabilities{MaxContext: 128000}
if !withContext.HasProviderData() {
t.Error("caps with max_context should have provider data")
}

View File

@@ -1,26 +1,14 @@
package providers
package capabilities
import (
"regexp"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ModelCapabilities describes what a model can do and its limits.
// Zero values mean "unknown / use heuristic".
type ModelCapabilities struct {
Streaming bool `json:"streaming"`
ToolCalling bool `json:"tool_calling"`
Vision bool `json:"vision"`
Thinking bool `json:"thinking"`
Reasoning bool `json:"reasoning"`
CodeOptimized bool `json:"code_optimized"`
WebSearch bool `json:"web_search"`
MaxContext int `json:"max_context"`
MaxOutputTokens int `json:"max_output_tokens"`
}
// ── Known Model Defaults ────────────────────
// Authoritative output limits for models where the provider API
// Authoritative capabilities for models where the provider API
// doesn't report them. Keyed by exact model ID or prefix.
//
// Sources:
@@ -29,7 +17,7 @@ type ModelCapabilities struct {
// Meta: Model cards on Hugging Face
// Google: https://ai.google.dev/gemini-api/docs/models
var knownModels = map[string]ModelCapabilities{
var knownModels = map[string]models.ModelCapabilities{
// ── Anthropic ────────────────────────────
"claude-opus-4": {
Streaming: true, ToolCalling: true, Vision: true, Thinking: true,
@@ -178,14 +166,8 @@ var knownModels = map[string]ModelCapabilities{
}
// LookupKnownModel finds capabilities for a model by exact ID or prefix match.
// Returns the caps and true if found, zero value and false if not.
func LookupKnownModel(modelID string) (ModelCapabilities, bool) {
id := strings.ToLower(modelID)
// Strip provider prefix (e.g. "anthropic/claude-sonnet-4-20250514" → "claude-sonnet-4-20250514")
if idx := strings.Index(id, "/"); idx >= 0 {
id = id[idx+1:]
}
func LookupKnownModel(modelID string) (models.ModelCapabilities, bool) {
id := normalizeModelID(modelID)
// Exact match first
if caps, ok := knownModels[id]; ok {
@@ -194,7 +176,7 @@ func LookupKnownModel(modelID string) (ModelCapabilities, bool) {
// Prefix match: "claude-sonnet-4-20250514" matches "claude-sonnet-4"
var bestKey string
var bestCaps ModelCapabilities
var bestCaps models.ModelCapabilities
for key, caps := range knownModels {
if strings.HasPrefix(id, key) && len(key) > len(bestKey) {
bestKey = key
@@ -205,13 +187,10 @@ func LookupKnownModel(modelID string) (ModelCapabilities, bool) {
return bestCaps, true
}
return ModelCapabilities{}, false
return models.ModelCapabilities{}, false
}
// ── Heuristic Capability Detection ──────────
// Ported from ai-editor's ProviderRegistry.
// Used for Ollama, LM Studio, and other generic endpoints
// that don't report capabilities in their /models response.
var (
toolPatterns = []*regexp.Regexp{
@@ -271,16 +250,10 @@ func matchesAny(id string, patterns []*regexp.Regexp) bool {
}
// InferCapabilities guesses model capabilities from the model ID string.
// This is the fallback when neither the known model table nor the provider
// API provides capability data.
func InferCapabilities(modelID string) ModelCapabilities {
id := strings.ToLower(modelID)
// Strip provider prefix
if idx := strings.Index(id, "/"); idx >= 0 {
id = id[idx+1:]
}
return ModelCapabilities{
// Fallback when neither the known model table nor the provider API has data.
func InferCapabilities(modelID string) models.ModelCapabilities {
id := normalizeModelID(modelID)
return models.ModelCapabilities{
Streaming: true, // virtually everything streams
ToolCalling: matchesAny(id, toolPatterns),
Vision: matchesAny(id, visionPatterns),
@@ -289,27 +262,41 @@ func InferCapabilities(modelID string) ModelCapabilities {
}
}
// ResolveMaxOutput returns the max output tokens for a model.
// ResolveIntrinsic determines the intrinsic capabilities of a model.
// Priority:
// 1. Explicit value in caps (from DB / provider API)
// 2. Known model table
// 3. Derive from context window (context/8, clamped 2048..16384)
// 4. 4096 as absolute last resort
// 1. catalogCaps (from model_catalog DB provider API data)
// 2. Known model table (static, compiled-in)
// 3. Heuristic inference (regex patterns)
//
// This is the ONE place the default lives. Nothing else in the
// codebase should hardcode a max_tokens value.
func ResolveMaxOutput(modelID string, caps ModelCapabilities) int {
// 1. Already set (from model_configs DB or provider API)
// This is the ONLY function that computes intrinsic capabilities.
func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) models.ModelCapabilities {
// Start with catalog data if available
var base models.ModelCapabilities
if catalogCaps != nil && catalogCaps.HasProviderData() {
base = *catalogCaps
}
// Fill gaps from known model table
if known, found := LookupKnownModel(modelID); found {
mergeGaps(&base, &known)
return base
}
// Fill gaps from heuristic inference
inferred := InferCapabilities(modelID)
mergeGaps(&base, &inferred)
return base
}
// ResolveMaxOutput returns the max output tokens for a model.
// Priority: explicit caps → known model table → derive from context → 4096 fallback.
func ResolveMaxOutput(modelID string, caps models.ModelCapabilities) int {
if caps.MaxOutputTokens > 0 {
return caps.MaxOutputTokens
}
// 2. Known model table
if known, ok := LookupKnownModel(modelID); ok && known.MaxOutputTokens > 0 {
return known.MaxOutputTokens
}
// 3. Derive from context window
if caps.MaxContext > 0 {
derived := caps.MaxContext / 8
if derived < 2048 {
@@ -320,77 +307,45 @@ func ResolveMaxOutput(modelID string, caps ModelCapabilities) int {
}
return derived
}
// 4. Last resort — the ONLY place 4096 appears as a default
return 4096
}
// MergeCapabilities takes authoritative caps (from provider API or DB) and fills
// gaps from the known model table and heuristic detection. Provider-reported data
// always wins; known table fills missing fields; heuristics are last resort.
func MergeCapabilities(authoritative ModelCapabilities, modelID string) ModelCapabilities {
merged := authoritative
// Fill gaps from known model table
known, found := LookupKnownModel(modelID)
if found {
if !merged.ToolCalling && known.ToolCalling {
merged.ToolCalling = true
}
if !merged.Vision && known.Vision {
merged.Vision = true
}
if !merged.Thinking && known.Thinking {
merged.Thinking = true
}
if !merged.Reasoning && known.Reasoning {
merged.Reasoning = true
}
if !merged.CodeOptimized && known.CodeOptimized {
merged.CodeOptimized = true
}
if !merged.WebSearch && known.WebSearch {
merged.WebSearch = true
}
if merged.MaxContext == 0 && known.MaxContext > 0 {
merged.MaxContext = known.MaxContext
}
if merged.MaxOutputTokens == 0 && known.MaxOutputTokens > 0 {
merged.MaxOutputTokens = known.MaxOutputTokens
}
return merged
// mergeGaps fills zero/false fields in dst from src. Never overrides existing data.
func mergeGaps(dst, src *models.ModelCapabilities) {
if !dst.Streaming && src.Streaming {
dst.Streaming = true
}
// No known model — fill gaps from heuristics
inferred := InferCapabilities(modelID)
if !merged.ToolCalling && inferred.ToolCalling {
merged.ToolCalling = true
if !dst.ToolCalling && src.ToolCalling {
dst.ToolCalling = true
}
if !merged.Vision && inferred.Vision {
merged.Vision = true
if !dst.Vision && src.Vision {
dst.Vision = true
}
if !merged.Thinking && inferred.Thinking {
merged.Thinking = true
if !dst.Thinking && src.Thinking {
dst.Thinking = true
}
if !merged.Reasoning && inferred.Reasoning {
merged.Reasoning = true
if !dst.Reasoning && src.Reasoning {
dst.Reasoning = true
}
if !merged.CodeOptimized && inferred.CodeOptimized {
merged.CodeOptimized = true
if !dst.CodeOptimized && src.CodeOptimized {
dst.CodeOptimized = true
}
if merged.MaxContext == 0 && inferred.MaxContext > 0 {
merged.MaxContext = inferred.MaxContext
if !dst.WebSearch && src.WebSearch {
dst.WebSearch = true
}
if merged.MaxOutputTokens == 0 && inferred.MaxOutputTokens > 0 {
merged.MaxOutputTokens = inferred.MaxOutputTokens
if dst.MaxContext == 0 && src.MaxContext > 0 {
dst.MaxContext = src.MaxContext
}
if dst.MaxOutputTokens == 0 && src.MaxOutputTokens > 0 {
dst.MaxOutputTokens = src.MaxOutputTokens
}
return merged
}
// HasProviderData returns true if this capability set contains any data that
// was likely reported by a provider (not just zero values).
func (c ModelCapabilities) HasProviderData() bool {
return c.ToolCalling || c.Vision || c.Thinking || c.Reasoning ||
c.CodeOptimized || c.WebSearch || c.MaxContext > 0 || c.MaxOutputTokens > 0
// normalizeModelID strips provider prefix and lowercases.
func normalizeModelID(modelID string) string {
id := strings.ToLower(modelID)
if idx := strings.Index(id, "/"); idx >= 0 {
id = id[idx+1:]
}
return id
}

View File

@@ -0,0 +1,276 @@
package capabilities
import (
"context"
"log"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ModelsForUser returns all models and Personas visible to a user.
//
// Visibility is controlled by the three-state visibility field on each
// catalog entry (enabled / team / disabled), applied per provider scope:
//
// Tier | enabled | team | disabled
// ----------+----------------+----------------------+---------
// Global | All users | Team admin → presets | Hidden
// Team | Team members | Team admin → presets | Hidden
// Personal | Owner direct | N/A | Hidden
//
// Sources aggregated:
// 1. Global catalog: enabled models → all authenticated users
// 2. Team catalog: enabled models → team members
// 3. Personal BYOK: enabled models → owner (if allow_user_byok)
// 4. Personas: global + team-scoped + personal + shared
// 5. User hidden preferences applied last
func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]models.UserModel, error) {
result := make([]models.UserModel, 0) // never nil — serializes as [] not null
// Load policies once
policies, err := stores.Policies.GetAll(ctx)
if err != nil {
return nil, err
}
allowBYOK := policies["allow_user_byok"] == "true"
// Load user's hidden preferences
hiddenMap, err := stores.UserSettings.GetHiddenModelIDs(ctx, userID)
if err != nil {
log.Printf("warn: failed to load user model settings: %v", err)
hiddenMap = make(map[string]bool)
}
// Get user's team IDs
teamIDs, err := stores.Teams.GetUserTeamIDs(ctx, userID)
if err != nil {
log.Printf("warn: failed to load user teams: %v", err)
}
// ── 1. Global enabled catalog models → all users ────
globalModels, err := stores.Catalog.ListVisible(ctx)
if err != nil {
return nil, err
}
// Build provider name lookup for global providers
globalProviders, _ := stores.Providers.ListGlobal(ctx)
providerMap := make(map[string]models.ProviderConfig)
for _, p := range globalProviders {
providerMap[p.ID] = p
}
for _, entry := range globalModels {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
prov := providerMap[entry.ProviderConfigID]
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
ModelID: entry.ModelID,
Source: "catalog",
ProviderConfigID: entry.ProviderConfigID,
ConfigID: entry.ProviderConfigID,
ProviderName: prov.Name,
ProviderType: prov.Provider,
Capabilities: caps,
Pricing: entry.Pricing,
Scope: models.ScopeGlobal,
Hidden: hiddenMap[entry.ModelID],
})
}
// ── 2. Team enabled catalog models → team members ────
for _, teamID := range teamIDs {
teamProviders, err := stores.Providers.ListForTeam(ctx, teamID)
if err != nil {
log.Printf("warn: failed to load team %s providers: %v", teamID, err)
continue
}
for _, prov := range teamProviders {
if !prov.IsActive {
continue
}
entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID)
if err != nil {
continue
}
for _, entry := range entries {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
ModelID: entry.ModelID,
Source: "catalog",
ProviderConfigID: prov.ID,
ConfigID: prov.ID,
ProviderName: prov.Name,
ProviderType: prov.Provider,
Capabilities: caps,
Pricing: entry.Pricing,
Scope: models.ScopeTeam,
OwnerID: prov.OwnerID,
Hidden: hiddenMap[entry.ModelID],
})
}
}
}
// ── 3. Personal BYOK enabled models → owner ────
if allowBYOK {
personalProviders, err := stores.Providers.ListForUser(ctx, userID)
if err != nil {
log.Printf("warn: failed to load personal providers: %v", err)
} else {
for _, prov := range personalProviders {
if !prov.IsActive {
continue
}
entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID)
if err != nil {
continue
}
for _, entry := range entries {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
ModelID: entry.ModelID,
Source: "catalog",
ProviderConfigID: prov.ID,
ConfigID: prov.ID,
ProviderName: prov.Name,
ProviderType: prov.Provider,
Capabilities: caps,
Pricing: entry.Pricing,
Scope: models.ScopePersonal,
OwnerID: &userID,
Hidden: hiddenMap[entry.ModelID],
})
}
}
}
}
// ── 4. Personas (always resolved) ────────────────
personas, err := stores.Personas.ListForUser(ctx, userID)
if err != nil {
log.Printf("warn: failed to load personas: %v", err)
} else {
for _, p := range personas {
// Resolve base model capabilities
var catalogCaps *models.ModelCapabilities
if p.ProviderConfigID != nil {
if entry, err := stores.Catalog.GetByModelID(ctx, *p.ProviderConfigID, p.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
// Fallback: look up any provider's catalog entry for this model
// (covers auto-resolve presets where provider_config_id is NULL)
if catalogCaps == nil {
if entry, err := stores.Catalog.GetByModelIDAny(ctx, p.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
caps := ResolveIntrinsic(p.BaseModelID, catalogCaps)
// Load tool grants
toolGrants, _ := stores.Personas.GetToolGrants(ctx, p.ID)
if len(toolGrants) > 0 {
caps.ToolCalling = true
}
// Look up provider info
var provName, provType string
if p.ProviderConfigID != nil {
if prov, err := stores.Providers.GetByID(ctx, *p.ProviderConfigID); err == nil {
provName = prov.Name
provType = prov.Provider
}
}
result = append(result, models.UserModel{
ID: p.ID,
DisplayName: p.Name,
ModelID: p.BaseModelID,
Source: "persona",
ProviderConfigID: deref(p.ProviderConfigID),
ConfigID: deref(p.ProviderConfigID),
ProviderName: provName,
ProviderType: provType,
Capabilities: caps,
IsPreset: true,
PresetID: p.ID,
PresetScope: p.Scope,
PresetAvatar: p.Avatar,
PresetTeamName: teamName(p, stores, ctx),
PersonaID: p.ID,
Description: p.Description,
Icon: p.Icon,
Avatar: p.Avatar,
SystemPrompt: p.SystemPrompt,
Temperature: p.Temperature,
MaxTokens: p.MaxTokens,
ToolGrants: toolGrants,
Scope: p.Scope,
OwnerID: p.OwnerID,
Hidden: hiddenMap[p.ID],
})
}
}
return result, nil
}
// ResolveForPersona returns effective capabilities for a specific Persona.
// Used at completion time to determine what tools/features are available.
func ResolveForPersona(ctx context.Context, stores store.Stores, persona *models.Persona) (models.ModelCapabilities, []string, error) {
var catalogCaps *models.ModelCapabilities
if persona.ProviderConfigID != nil {
if entry, err := stores.Catalog.GetByModelID(ctx, *persona.ProviderConfigID, persona.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
// Fallback: any provider's catalog entry (auto-resolve presets)
if catalogCaps == nil {
if entry, err := stores.Catalog.GetByModelIDAny(ctx, persona.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
caps := ResolveIntrinsic(persona.BaseModelID, catalogCaps)
toolGrants, err := stores.Personas.GetToolGrants(ctx, persona.ID)
if err != nil {
return caps, nil, err
}
return caps, toolGrants, nil
}
func displayName(name, modelID string) string {
if name != "" {
return name
}
return modelID
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
// teamName resolves the team_name for a persona's owner (if team-scoped).
func teamName(p models.Persona, stores store.Stores, ctx context.Context) string {
if p.Scope != models.ScopeTeam || p.OwnerID == nil {
return ""
}
team, err := stores.Teams.GetByID(ctx, *p.OwnerID)
if err != nil {
return ""
}
return team.Name
}

View File

@@ -1,148 +1,156 @@
package database
import (
"database/sql"
"embed"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// schemaVersion tracks the latest applied migration.
var schemaVersion string = "none"
// Migrate checks the database schema state and applies any pending
// migrations. This runs at startup before the HTTP server opens.
//
// Flow:
// 1. Ping DB (health check)
// 2. Ensure schema_migrations table exists
// 3. Load applied versions
// 4. Discover embedded SQL files
// 5. Apply pending migrations in order
//
// All migrations run in individual transactions. A failed migration
// aborts startup — the backend will not serve traffic with an
// inconsistent schema.
// SchemaVersion returns the current schema version string.
func SchemaVersion() string { return schemaVersion }
// Migrate runs all pending migrations. It creates the schema_migrations
// tracking table if it doesn't exist, then applies each .sql file that
// hasn't been applied yet, in order.
func Migrate() error {
if DB == nil {
return fmt.Errorf("database not connected")
}
start := time.Now()
log.Println("📋 Schema migration check...")
// ── 1. Health check ─────────────────────────
if err := DB.Ping(); err != nil {
return fmt.Errorf("database unreachable: %w", err)
}
// ── 2. Ensure tracking table exists ─────────
// Ensure tracking table exists
_, err := DB.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY,
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW()
)
`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
return fmt.Errorf("create migrations table: %w", err)
}
// ── 3. Load already-applied versions ────────
applied := make(map[string]bool)
rows, err := DB.Query(`SELECT version FROM schema_migrations`)
// Find migration files
migrationsDir := findMigrationsDir()
if migrationsDir == "" {
log.Println("⚠ No migrations directory found — skipping schema migration")
return nil
}
entries, err := os.ReadDir(migrationsDir)
if err != nil {
return fmt.Errorf("read schema_migrations: %w", err)
}
defer rows.Close()
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return fmt.Errorf("scan version: %w", err)
}
applied[v] = true
}
// ── 4. Discover embedded migration files ────
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
return fmt.Errorf("read migrations dir: %w", err)
}
// Collect and sort .sql files
var files []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
files = append(files, e.Name())
}
}
sort.Strings(files) // lexicographic = version order (001_, 002_, ...)
sort.Strings(files)
// ── 5. Apply pending ────────────────────────
pending := 0
skipped := 0
for _, name := range files {
if applied[name] {
skipped++
if len(files) == 0 {
log.Println(" No migration files found")
return nil
}
// Compat: rename old numeric-only version entries to full filenames.
// Earlier extractVersion used regex ^(\d+), recording "001" instead of
// "001_v09_schema.sql". Fix them in-place so CI's db-migrate.sh matches.
for _, file := range files {
prefix := strings.SplitN(file, "_", 2)[0] // "001"
DB.Exec("UPDATE schema_migrations SET version = $1 WHERE version = $2 AND version != $1", file, prefix)
}
// Apply pending migrations
applied := 0
for _, file := range files {
version := extractVersion(file)
if version == "" {
continue
}
content, err := migrationsFS.ReadFile("migrations/" + name)
// Check if already applied
var exists bool
DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists)
if exists {
schemaVersion = version
continue
}
// Read and execute
path := filepath.Join(migrationsDir, file)
sql, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
return fmt.Errorf("read %s: %w", file, err)
}
log.Printf(" ▶ applying: %s", name)
tx, err := DB.Begin()
if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err)
log.Printf(" Applying migration %s...", file)
if _, err := DB.Exec(string(sql)); err != nil {
return fmt.Errorf("apply %s: %w", file, err)
}
if _, err := tx.Exec(string(content)); err != nil {
tx.Rollback()
return fmt.Errorf("migration %s failed: %w", name, err)
// Record
if _, err := DB.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil {
return fmt.Errorf("record %s: %w", file, err)
}
if _, err := tx.Exec(
`INSERT INTO schema_migrations (version) VALUES ($1)`, name,
); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
log.Printf(" ✓ %s applied", name)
pending++
schemaVersion = version
applied++
}
elapsed := time.Since(start).Round(time.Millisecond)
if pending > 0 {
log.Printf("✅ Migrations complete: %d applied, %d skipped (%s)", pending, skipped, elapsed)
if applied > 0 {
log.Printf(" ✅ Applied %d migration(s), schema at %s", applied, schemaVersion)
} else {
log.Printf(" Schema up to date (%d migrations, %s)", skipped, elapsed)
log.Printf(" Schema up to date at %s", schemaVersion)
}
return nil
}
// SchemaVersion returns the most recently applied migration version,
// or "" if no migrations have been applied.
func SchemaVersion() string {
if DB == nil {
// extractVersion returns the filename as the version key if it's a valid
// migration file (starts with digit, ends with .sql).
// "001_v09_schema.sql" → "001_v09_schema.sql" (matches db-migrate.sh convention)
func extractVersion(filename string) string {
// Use full filename as version to match db-migrate.sh convention
if !strings.HasSuffix(filename, ".sql") {
return ""
}
var version sql.NullString
err := DB.QueryRow(`
SELECT version FROM schema_migrations
ORDER BY version DESC LIMIT 1
`).Scan(&version)
if err != nil || !version.Valid {
// Must start with a digit (e.g., 001_v09_schema.sql)
if len(filename) == 0 || filename[0] < '0' || filename[0] > '9' {
return ""
}
return version.String
return filename
}
// findMigrationsDir locates the migrations directory.
// Checks relative to the binary, then relative to the source file.
func findMigrationsDir() string {
candidates := []string{
"database/migrations",
"server/database/migrations",
"../database/migrations",
}
// Also check relative to this source file (for tests)
_, thisFile, _, ok := runtime.Caller(0)
if ok {
dir := filepath.Dir(thisFile)
candidates = append(candidates, filepath.Join(dir, "migrations"))
}
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && info.IsDir() {
return c
}
}
return ""
}

View File

@@ -1,408 +0,0 @@
-- ==========================================
-- Chat Switchboard - PostgreSQL Schema
-- ==========================================
-- Enable extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "vector"; -- For embeddings (pgvector)
-- ==========================================
-- Core Tables
-- ==========================================
-- Users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name VARCHAR(100),
avatar_url TEXT,
role VARCHAR(20) DEFAULT 'user', -- user, admin, moderator
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_login_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
-- API Configurations (user's API keys)
CREATE TABLE api_configs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL, -- "OpenAI", "Claude", "Local Ollama"
provider VARCHAR(50) NOT NULL, -- openai, anthropic, ollama, openrouter
endpoint TEXT NOT NULL,
api_key_encrypted TEXT, -- Encrypted at rest
model_default VARCHAR(100),
config JSONB DEFAULT '{}'::jsonb, -- Custom settings per provider
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_api_configs_user ON api_configs(user_id);
-- ==========================================
-- Feature 1: CHATS (User to LLM)
-- ==========================================
CREATE TABLE chats (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
model VARCHAR(100), -- Current model for this chat
api_config_id UUID REFERENCES api_configs(id) ON DELETE SET NULL,
system_prompt TEXT,
settings JSONB DEFAULT '{}'::jsonb, -- temperature, max_tokens, etc
is_archived BOOLEAN DEFAULT false,
is_pinned BOOLEAN DEFAULT false,
folder VARCHAR(100), -- For organization
tags TEXT[], -- For filtering
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_chats_user ON chats(user_id);
CREATE INDEX idx_chats_updated ON chats(updated_at DESC);
CREATE INDEX idx_chats_tags ON chats USING GIN(tags);
CREATE TABLE chat_messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
chat_id UUID REFERENCES chats(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL, -- user, assistant, system, tool
content TEXT NOT NULL,
model VARCHAR(100), -- Which model generated this (for assistant)
tokens_used INTEGER,
tool_calls JSONB, -- Function calls made
metadata JSONB DEFAULT '{}'::jsonb, -- thinking_blocks, attachments, etc
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_chat_messages_chat ON chat_messages(chat_id, created_at);
-- Model routing history (for analytics/debugging)
CREATE TABLE model_routing_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
chat_id UUID REFERENCES chats(id) ON DELETE CASCADE,
message_id UUID REFERENCES chat_messages(id) ON DELETE CASCADE,
requested_model VARCHAR(100),
routed_model VARCHAR(100),
reason TEXT, -- "cost_optimization", "context_length", "manual", "fallback"
latency_ms INTEGER,
cost_usd NUMERIC(10, 6),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_routing_log_chat ON model_routing_log(chat_id);
-- ==========================================
-- Feature 2: CHANNELS (User to User + AI)
-- ==========================================
CREATE TABLE channels (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
type VARCHAR(20) DEFAULT 'public', -- public, private, dm
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
settings JSONB DEFAULT '{}'::jsonb, -- ai_participants, webhooks, etc
is_archived BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_channels_name ON channels(name);
CREATE INDEX idx_channels_type ON channels(type);
-- Channel memberships
CREATE TABLE channel_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) DEFAULT 'member', -- owner, admin, member
joined_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX idx_channel_members_channel ON channel_members(channel_id);
CREATE INDEX idx_channel_members_user ON channel_members(user_id);
-- Channel messages
CREATE TABLE channel_messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL, -- NULL for AI messages
content TEXT NOT NULL,
mentions JSONB, -- {users: [uuid], models: [name]}
thread_id UUID REFERENCES channel_messages(id) ON DELETE CASCADE, -- For threading
attachments JSONB, -- Files, images, etc
reactions JSONB DEFAULT '{}'::jsonb, -- {emoji: [user_ids]}
is_ai_message BOOLEAN DEFAULT false,
ai_model VARCHAR(100), -- If AI generated
edited_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_channel_messages_channel ON channel_messages(channel_id, created_at DESC);
CREATE INDEX idx_channel_messages_thread ON channel_messages(thread_id);
CREATE INDEX idx_channel_messages_mentions ON channel_messages USING GIN(mentions);
-- ==========================================
-- Feature 3: NOTES & KNOWLEDGE BASES
-- ==========================================
CREATE TABLE notes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
content_type VARCHAR(20) DEFAULT 'markdown', -- markdown, html, plain
folder VARCHAR(100),
tags TEXT[],
is_pinned BOOLEAN DEFAULT false,
is_shared BOOLEAN DEFAULT false,
share_token UUID UNIQUE DEFAULT uuid_generate_v4(),
parent_note_id UUID REFERENCES notes(id) ON DELETE SET NULL, -- For hierarchies
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_notes_user ON notes(user_id);
CREATE INDEX idx_notes_updated ON notes(updated_at DESC);
CREATE INDEX idx_notes_tags ON notes USING GIN(tags);
CREATE INDEX idx_notes_share_token ON notes(share_token) WHERE is_shared = true;
-- Knowledge Bases (Collections of documents)
CREATE TABLE knowledge_bases (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
embedding_model VARCHAR(100) DEFAULT 'text-embedding-ada-002',
settings JSONB DEFAULT '{}'::jsonb, -- chunk_size, overlap, etc
is_public BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_knowledge_bases_user ON knowledge_bases(user_id);
-- Documents in knowledge bases
CREATE TABLE kb_documents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE,
filename VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
content_type VARCHAR(50), -- application/pdf, text/markdown, etc
file_size INTEGER,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_kb_documents_kb ON kb_documents(kb_id);
-- Document chunks (for RAG)
CREATE TABLE kb_chunks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
document_id UUID REFERENCES kb_documents(id) ON DELETE CASCADE,
kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE,
content TEXT NOT NULL,
embedding vector(1536), -- For pgvector similarity search
chunk_index INTEGER,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_kb_chunks_document ON kb_chunks(document_id);
CREATE INDEX idx_kb_chunks_embedding ON kb_chunks USING ivfflat (embedding vector_cosine_ops);
-- ==========================================
-- Feature 4: PLUGIN ORCHESTRATION
-- ==========================================
-- Installed extensions/plugins
CREATE TABLE extensions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) UNIQUE NOT NULL,
version VARCHAR(20) NOT NULL,
author VARCHAR(100),
description TEXT,
runtime VARCHAR(20), -- python, go, node, rust
entry_point TEXT NOT NULL,
port INTEGER,
manifest JSONB NOT NULL, -- Full extension.json
is_enabled BOOLEAN DEFAULT true,
is_system BOOLEAN DEFAULT false, -- Core extensions
install_source VARCHAR(200), -- URL or marketplace ID
installed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_extensions_enabled ON extensions(is_enabled);
-- Extension tools/functions
CREATE TABLE extension_tools (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
extension_id UUID REFERENCES extensions(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
parameters_schema JSONB NOT NULL, -- JSON Schema
response_schema JSONB,
is_enabled BOOLEAN DEFAULT true,
UNIQUE(extension_id, name)
);
CREATE INDEX idx_extension_tools_extension ON extension_tools(extension_id);
-- Tool usage log (for analytics)
CREATE TABLE tool_usage_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tool_id UUID REFERENCES extension_tools(id) ON DELETE SET NULL,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
chat_id UUID REFERENCES chats(id) ON DELETE SET NULL,
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
input_params JSONB,
output_result JSONB,
execution_time_ms INTEGER,
success BOOLEAN,
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_tool_usage_tool ON tool_usage_log(tool_id);
CREATE INDEX idx_tool_usage_user ON tool_usage_log(user_id);
-- ==========================================
-- Feature 5: WORKFLOWS (Unique Feature!)
-- ==========================================
-- Workflow definitions (DAG of AI operations)
CREATE TABLE workflows (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
graph JSONB NOT NULL, -- Node-edge DAG structure
input_schema JSONB, -- Expected inputs
output_schema JSONB, -- Expected outputs
is_public BOOLEAN DEFAULT false,
is_template BOOLEAN DEFAULT false,
tags TEXT[],
usage_count INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_workflows_user ON workflows(user_id);
CREATE INDEX idx_workflows_public ON workflows(is_public) WHERE is_public = true;
CREATE INDEX idx_workflows_tags ON workflows USING GIN(tags);
-- Workflow executions
CREATE TABLE workflow_executions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workflow_id UUID REFERENCES workflows(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
input_data JSONB,
output_data JSONB,
status VARCHAR(20), -- running, completed, failed
steps JSONB, -- Execution trace
total_cost_usd NUMERIC(10, 6),
total_time_ms INTEGER,
error_message TEXT,
started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_workflow_executions_workflow ON workflow_executions(workflow_id);
CREATE INDEX idx_workflow_executions_user ON workflow_executions(user_id);
-- ==========================================
-- Shared/Utility Tables
-- ==========================================
-- File uploads
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
filename VARCHAR(500) NOT NULL,
content_type VARCHAR(100),
file_size INTEGER,
storage_path TEXT NOT NULL, -- S3/local path
is_public BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_files_user ON files(user_id);
-- Webhooks
CREATE TABLE webhooks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100),
url TEXT NOT NULL,
events TEXT[] NOT NULL, -- chat.message, channel.message, etc
secret VARCHAR(100),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_webhooks_user ON webhooks(user_id);
-- Audit log
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50),
resource_id UUID,
metadata JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_audit_log_user ON audit_log(user_id);
CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC);
-- ==========================================
-- Functions & Triggers
-- ==========================================
-- Auto-update updated_at timestamps
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER chats_updated_at BEFORE UPDATE ON chats
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER notes_updated_at BEFORE UPDATE ON notes
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER knowledge_bases_updated_at BEFORE UPDATE ON knowledge_bases
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER workflows_updated_at BEFORE UPDATE ON workflows
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Increment workflow usage count
CREATE OR REPLACE FUNCTION increment_workflow_usage()
RETURNS TRIGGER AS $$
BEGIN
UPDATE workflows SET usage_count = usage_count + 1 WHERE id = NEW.workflow_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER workflow_executions_insert AFTER INSERT ON workflow_executions
FOR EACH ROW EXECUTE FUNCTION increment_workflow_usage();

View File

@@ -0,0 +1,585 @@
-- ==========================================
-- Chat Switchboard — v0.9.0 Consolidated Schema
-- ==========================================
-- Clean-slate schema. Replaces all 001021 migrations.
-- Drop DB and re-create before applying.
--
-- Design principles:
-- • Explicit scope enums over nullable column tri-states
-- • Personas as trust boundaries with extensible grants
-- • Secure by default (models hidden until admin enables)
-- • Only tables with active handlers — no placeholder tables
-- ==========================================
-- ── Extensions ──────────────────────────────
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
-- ── Utility: auto-update updated_at ─────────
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- =========================================
-- 1. USERS
-- =========================================
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name VARCHAR(100),
avatar_url TEXT,
role VARCHAR(20) DEFAULT 'user'
CHECK (role IN ('user', 'admin', 'moderator')),
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
DROP TRIGGER IF EXISTS users_updated_at ON users;
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- =========================================
-- 2. AUTH
-- =========================================
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL;
-- =========================================
-- 3. TEAMS
-- =========================================
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL UNIQUE,
description TEXT DEFAULT '',
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_teams_active ON teams(is_active) WHERE is_active = true;
DROP TRIGGER IF EXISTS teams_updated_at ON teams;
CREATE TRIGGER teams_updated_at BEFORE UPDATE ON teams
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON COLUMN teams.settings IS 'Team policies: {"require_private_providers": false}';
CREATE TABLE IF NOT EXISTS team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member'
CHECK (role IN ('admin', 'member')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(team_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members(team_id);
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
-- =========================================
-- 4. PROVIDER CONFIGS (replaces api_configs)
-- =========================================
-- Explicit scope enum replaces nullable column tri-state.
-- scope='global': admin-managed, visible to all users (owner_id IS NULL)
-- scope='team': team admin-managed (owner_id = teams.id)
-- scope='personal': user's own keys (owner_id = users.id)
CREATE TABLE IF NOT EXISTS provider_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scope VARCHAR(10) NOT NULL
CHECK (scope IN ('global', 'team', 'personal')),
owner_id UUID,
name VARCHAR(100) NOT NULL,
provider VARCHAR(50) NOT NULL,
endpoint TEXT NOT NULL,
api_key_enc TEXT,
model_default VARCHAR(100),
config JSONB DEFAULT '{}'::jsonb,
headers JSONB DEFAULT '{}'::jsonb,
settings JSONB DEFAULT '{}'::jsonb,
is_active BOOLEAN DEFAULT true,
is_private BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
CREATE INDEX IF NOT EXISTS idx_provider_configs_owner ON provider_configs(owner_id) WHERE owner_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_provider_configs_active ON provider_configs(is_active) WHERE is_active = true;
DROP TRIGGER IF EXISTS provider_configs_updated_at ON provider_configs;
CREATE TRIGGER provider_configs_updated_at BEFORE UPDATE ON provider_configs
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON COLUMN provider_configs.scope IS 'global=admin-managed, team=team-scoped, personal=user BYOK';
COMMENT ON COLUMN provider_configs.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal scope';
COMMENT ON COLUMN provider_configs.headers IS 'Custom HTTP headers (e.g. OpenRouter HTTP-Referer)';
COMMENT ON COLUMN provider_configs.settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)';
COMMENT ON COLUMN provider_configs.is_private IS 'Data stays on-prem (local/self-hosted provider)';
-- =========================================
-- 5. MODEL CATALOG (replaces model_configs)
-- =========================================
-- Intrinsic capabilities for models the system knows about.
-- Hidden by default — admin must enable.
CREATE TABLE IF NOT EXISTS model_catalog (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
display_name TEXT,
capabilities JSONB NOT NULL DEFAULT '{}',
pricing JSONB,
visibility VARCHAR(10) DEFAULT 'disabled'
CHECK (visibility IN ('enabled', 'disabled', 'team')),
last_synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(provider_config_id, model_id)
);
CREATE INDEX IF NOT EXISTS idx_model_catalog_provider ON model_catalog(provider_config_id);
CREATE INDEX IF NOT EXISTS idx_model_catalog_enabled ON model_catalog(visibility) WHERE visibility = 'enabled';
-- Fix CHECK constraint and default for existing databases (idempotent)
DO $$ BEGIN
ALTER TABLE model_catalog DROP CONSTRAINT IF EXISTS model_catalog_visibility_check;
-- Remap old values before adding new constraint
UPDATE model_catalog SET visibility = 'enabled' WHERE visibility = 'visible';
UPDATE model_catalog SET visibility = 'disabled' WHERE visibility = 'hidden';
ALTER TABLE model_catalog ADD CONSTRAINT model_catalog_visibility_check
CHECK (visibility IN ('enabled', 'disabled', 'team'));
ALTER TABLE model_catalog ALTER COLUMN visibility SET DEFAULT 'disabled';
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
DROP TRIGGER IF EXISTS model_catalog_updated_at ON model_catalog;
CREATE TRIGGER model_catalog_updated_at BEFORE UPDATE ON model_catalog
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON COLUMN model_catalog.visibility IS 'hidden by default — admin must enable for global models';
COMMENT ON COLUMN model_catalog.capabilities IS 'Intrinsic: {"streaming","tool_calling","vision","thinking","reasoning","code_optimized","web_search","max_context","max_output_tokens"}';
-- =========================================
-- 6. PERSONAS (replaces model_presets)
-- =========================================
CREATE TABLE IF NOT EXISTS personas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
description TEXT DEFAULT '',
icon VARCHAR(10) DEFAULT '',
avatar TEXT DEFAULT '',
-- Base model binding
base_model_id TEXT NOT NULL,
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
-- Behavioral configuration
system_prompt TEXT DEFAULT '',
temperature FLOAT,
max_tokens INT,
thinking_budget INT,
top_p FLOAT,
-- Scope & ownership
scope VARCHAR(10) NOT NULL
CHECK (scope IN ('global', 'team', 'personal')),
owner_id UUID,
created_by UUID NOT NULL REFERENCES users(id),
-- State
is_active BOOLEAN DEFAULT true,
is_shared BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope);
CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id) WHERE owner_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active) WHERE is_active = true;
DROP TRIGGER IF EXISTS personas_updated_at ON personas;
CREATE TRIGGER personas_updated_at BEFORE UPDATE ON personas
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON COLUMN personas.scope IS 'global=all users, team=team members, personal=creator only';
COMMENT ON COLUMN personas.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal';
COMMENT ON COLUMN personas.is_shared IS 'Personal Personas shared with others (read-only)';
-- =========================================
-- 7. PERSONA GRANTS (extensible resource binding)
-- =========================================
CREATE TABLE IF NOT EXISTS persona_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
grant_type VARCHAR(30) NOT NULL,
grant_ref TEXT NOT NULL,
config JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(persona_id, grant_type, grant_ref)
);
CREATE INDEX IF NOT EXISTS idx_persona_grants_persona ON persona_grants(persona_id);
CREATE INDEX IF NOT EXISTS idx_persona_grants_type ON persona_grants(grant_type);
COMMENT ON COLUMN persona_grants.grant_type IS 'Extensible: tool, knowledge_base (future), api_endpoint (future)';
COMMENT ON COLUMN persona_grants.grant_ref IS 'tool: function name. knowledge_base: UUID. api_endpoint: identifier.';
COMMENT ON COLUMN persona_grants.config IS 'Type-specific config, e.g. {"read_only": true} for KB grants';
-- =========================================
-- 8. PLATFORM POLICIES (replaces scattered global_settings checks)
-- =========================================
CREATE TABLE IF NOT EXISTS platform_policies (
key VARCHAR(50) PRIMARY KEY,
value TEXT NOT NULL,
updated_by UUID REFERENCES users(id),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Secure by default
INSERT INTO platform_policies (key, value) VALUES
('allow_user_byok', 'false'),
('allow_user_personas', 'false'),
('allow_raw_model_access', 'true'),
('allow_registration', 'true'),
('default_user_active', 'false'),
('allow_team_providers', 'true')
ON CONFLICT (key) DO NOTHING;
COMMENT ON TABLE platform_policies IS 'Global admin switches controlling platform behavior';
-- =========================================
-- 9. GLOBAL SETTINGS (non-policy config)
-- =========================================
-- Retained for banner config, site branding, etc.
-- Policy-like keys move to platform_policies.
CREATE TABLE IF NOT EXISTS global_settings (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ DEFAULT NOW(),
updated_by UUID REFERENCES users(id)
);
-- Seed defaults
INSERT INTO global_settings (key, value) VALUES
('registration', '{"enabled": true}'::jsonb),
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb),
('banner', '{
"enabled": false,
"text": "",
"position": "both",
"bg": "#007a33",
"fg": "#ffffff"
}'::jsonb),
('banner_presets', '{
"development": { "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff" },
"testing": { "text": "TESTING", "bg": "#502b85", "fg": "#ffffff" },
"staging": { "text": "STAGING", "bg": "#0033a0", "fg": "#ffffff" },
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
"demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" }
}'::jsonb)
ON CONFLICT (key) DO NOTHING;
-- =========================================
-- 10. USER MODEL SETTINGS (replaces user_model_preferences)
-- =========================================
CREATE TABLE IF NOT EXISTS user_model_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
hidden BOOLEAN DEFAULT false,
preferred_temperature FLOAT,
preferred_max_tokens INT,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, model_id)
);
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
DROP TRIGGER IF EXISTS user_model_settings_updated_at ON user_model_settings;
CREATE TRIGGER user_model_settings_updated_at BEFORE UPDATE ON user_model_settings
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- =========================================
-- 11. CHANNELS (unified chats)
-- =========================================
CREATE TABLE IF NOT EXISTS channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
description TEXT,
type VARCHAR(20) DEFAULT 'direct'
CHECK (type IN ('direct', 'group', 'channel')),
model VARCHAR(100),
system_prompt TEXT,
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
is_archived BOOLEAN DEFAULT false,
is_pinned BOOLEAN DEFAULT false,
folder_id UUID, -- FK added after folders table
folder TEXT, -- backward compat: simple text folder name
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
settings JSONB DEFAULT '{}'::jsonb,
tags TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id);
CREATE INDEX IF NOT EXISTS idx_channels_updated ON channels(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id) WHERE team_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_channels_tags ON channels USING GIN(tags);
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, channel=named persistent';
-- =========================================
-- 12. MESSAGES (with tree/forking support)
-- =========================================
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL
CHECK (role IN ('user', 'assistant', 'system', 'tool')),
content TEXT NOT NULL,
model VARCHAR(100),
tokens_used INTEGER,
tool_calls JSONB,
metadata JSONB DEFAULT '{}'::jsonb,
parent_id UUID REFERENCES messages(id) ON DELETE SET NULL,
sibling_index INTEGER DEFAULT 0,
participant_type VARCHAR(10) DEFAULT 'user',
participant_id VARCHAR(255),
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
CREATE INDEX IF NOT EXISTS idx_messages_alive ON messages(channel_id, created_at)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_messages_parent_alive ON messages(parent_id, sibling_index)
WHERE deleted_at IS NULL;
COMMENT ON COLUMN messages.parent_id IS 'Tree parent for conversation forking';
COMMENT ON COLUMN messages.sibling_index IS 'Position among siblings (0-indexed)';
COMMENT ON COLUMN messages.participant_type IS 'user or model';
COMMENT ON COLUMN messages.participant_id IS 'user UUID or model identifier string';
-- =========================================
-- 13. CHANNEL MEMBERS & MODELS
-- =========================================
CREATE TABLE IF NOT EXISTS channel_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) DEFAULT 'member',
joined_at TIMESTAMPTZ DEFAULT NOW(),
last_read_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_members_channel ON channel_members(channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_members_user ON channel_members(user_id);
CREATE TABLE IF NOT EXISTS channel_models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
model_id VARCHAR(255) NOT NULL,
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
display_name VARCHAR(100),
system_prompt TEXT,
settings JSONB DEFAULT '{}'::jsonb,
is_default BOOLEAN DEFAULT false,
added_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, model_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
-- =========================================
-- 14. CHANNEL CURSORS (forking navigation)
-- =========================================
CREATE TABLE IF NOT EXISTS channel_cursors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
active_leaf_id UUID REFERENCES messages(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_cursors_channel ON channel_cursors(channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id);
-- =========================================
-- 15. FOLDERS & PROJECTS
-- =========================================
CREATE TABLE IF NOT EXISTS folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, name, parent_id)
);
CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id);
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);
DROP TRIGGER IF EXISTS folders_updated_at ON folders;
CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Now add the FK from channels
DO $$ BEGIN
ALTER TABLE channels ADD CONSTRAINT fk_channels_folder
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
color VARCHAR(7),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_projects_user ON projects(user_id);
DROP TRIGGER IF EXISTS projects_updated_at ON projects;
CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TABLE IF NOT EXISTS project_channels (
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (project_id, channel_id)
);
CREATE INDEX IF NOT EXISTS idx_project_channels_channel ON project_channels(channel_id);
-- =========================================
-- 16. NOTES
-- =========================================
CREATE TABLE IF NOT EXISTS notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content TEXT DEFAULT '',
folder_path TEXT DEFAULT '/',
tags TEXT[] DEFAULT '{}',
metadata JSONB DEFAULT '{}',
source_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
search_vector TSVECTOR,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id);
CREATE INDEX IF NOT EXISTS idx_notes_search ON notes USING GIN(search_vector);
CREATE INDEX IF NOT EXISTS idx_notes_folder ON notes(user_id, folder_path);
CREATE INDEX IF NOT EXISTS idx_notes_tags ON notes USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_notes_team ON notes(team_id) WHERE team_id IS NOT NULL;
CREATE OR REPLACE FUNCTION notes_search_update_fn()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.content, '')), 'B');
NEW.updated_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS notes_search_update ON notes;
CREATE TRIGGER notes_search_update
BEFORE INSERT OR UPDATE OF title, content ON notes
FOR EACH ROW EXECUTE FUNCTION notes_search_update_fn();
-- =========================================
-- 17. AUDIT LOG
-- =========================================
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id VARCHAR(255),
metadata JSONB DEFAULT '{}'::jsonb,
ip_address VARCHAR(45),
user_agent TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id) WHERE actor_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
COMMENT ON TABLE audit_log IS 'Immutable audit trail of all mutating operations';

View File

@@ -1,20 +0,0 @@
-- ==========================================
-- Chat Switchboard - Refresh Tokens
-- ==========================================
-- Supports JWT refresh token rotation.
-- Old tokens are revoked on each refresh.
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
revoked_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL;
-- Cleanup: auto-delete expired tokens older than 30 days
-- (run periodically or via pg_cron if available)

View File

@@ -1,15 +0,0 @@
-- Migration 003: Global Settings
-- Stores application-wide configuration managed by admins.
CREATE TABLE IF NOT EXISTS global_settings (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_by UUID REFERENCES users(id)
);
-- Seed defaults
INSERT INTO global_settings (key, value) VALUES
('registration', '{"enabled": true}'::jsonb),
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb)
ON CONFLICT (key) DO NOTHING;

View File

@@ -1,12 +0,0 @@
-- Model configurations: admin-curated list of available models
CREATE TABLE IF NOT EXISTS model_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
api_config_id UUID REFERENCES api_configs(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
display_name TEXT,
is_enabled BOOLEAN DEFAULT true,
capabilities JSONB DEFAULT '{"tool": false, "thinking": false, "vision": false, "code": false}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(api_config_id, model_id)
);

View File

@@ -1,60 +0,0 @@
-- Migration 005: Provider Capabilities & Custom Headers
--
-- Adds provider-level configuration (custom headers, provider-specific settings)
-- and expands model capabilities to include output token limits.
-- This eliminates hardcoded max_tokens defaults throughout the system.
-- ── api_configs: custom headers and global flag ──
ALTER TABLE api_configs
ADD COLUMN IF NOT EXISTS custom_headers JSONB DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS provider_settings JSONB DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS is_global BOOLEAN DEFAULT false;
COMMENT ON COLUMN api_configs.custom_headers IS 'Extra HTTP headers sent with every request (e.g. OpenRouter HTTP-Referer)';
COMMENT ON COLUMN api_configs.provider_settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)';
COMMENT ON COLUMN api_configs.is_global IS 'Admin-managed configs visible to all users';
-- Backfill: existing admin-created configs (user_id IS NULL) are global
UPDATE api_configs SET is_global = true WHERE user_id IS NULL;
-- ── model_configs: richer capabilities ──
-- The existing capabilities JSONB gets richer fields.
-- No schema change needed (it's JSONB), but let's update existing rows
-- that have the old minimal shape to include the new fields.
-- New canonical shape:
-- {
-- "streaming": true,
-- "tool_calling": false,
-- "vision": false,
-- "thinking": false,
-- "reasoning": false,
-- "code_optimized": false,
-- "max_context": 0,
-- "max_output_tokens": 0,
-- "web_search": false
-- }
-- max_output_tokens = 0 means "not set, use provider/heuristic default"
-- Migrate old "tool" key to "tool_calling" for consistency
UPDATE model_configs
SET capabilities = capabilities - 'tool' || jsonb_build_object('tool_calling', COALESCE(capabilities->>'tool', 'false')::boolean)
WHERE capabilities ? 'tool' AND NOT capabilities ? 'tool_calling';
-- Migrate old "code" key to "code_optimized"
UPDATE model_configs
SET capabilities = capabilities - 'code' || jsonb_build_object('code_optimized', COALESCE(capabilities->>'code', 'false')::boolean)
WHERE capabilities ? 'code' AND NOT capabilities ? 'code_optimized';
-- ── user_model_preferences ──
-- Users can enable/disable models from global providers for personal use.
CREATE TABLE IF NOT EXISTS user_model_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
model_config_id UUID NOT NULL REFERENCES model_configs(id) ON DELETE CASCADE,
is_enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, model_config_id)
);
CREATE INDEX IF NOT EXISTS idx_user_model_prefs_user ON user_model_preferences(user_id);

View File

@@ -1,114 +0,0 @@
-- ==========================================
-- Migration 006: Unify Chats → Channels
-- ==========================================
-- "Everything is a channel." Merges the separate chats/channels
-- schema into a single unified channel model.
--
-- The old channels/channel_members/channel_messages tables (from 001)
-- were never populated — safe to drop and rebuild on top of chats.
-- ==========================================
-- ── 1. Drop unused legacy channel tables ────
-- These were placeholders from 001; no data, no handlers.
-- Order matters: drop dependents first.
-- Remove FK references in tool_usage_log before dropping
ALTER TABLE tool_usage_log DROP CONSTRAINT IF EXISTS tool_usage_log_channel_id_fkey;
ALTER TABLE tool_usage_log DROP COLUMN IF EXISTS channel_id;
DROP TABLE IF EXISTS channel_messages CASCADE;
DROP TABLE IF EXISTS channel_members CASCADE;
DROP TABLE IF EXISTS channels CASCADE;
-- Drop the old trigger (will re-create after rename)
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
-- ── 2. Rename chats → channels ──────────────
ALTER TABLE chats RENAME TO channels;
ALTER TABLE chat_messages RENAME TO messages;
-- Rename columns to match new schema
ALTER TABLE messages RENAME COLUMN chat_id TO channel_id;
-- Rename indexes
ALTER INDEX IF EXISTS idx_chats_user RENAME TO idx_channels_user;
ALTER INDEX IF EXISTS idx_chats_updated RENAME TO idx_channels_updated;
ALTER INDEX IF EXISTS idx_chats_tags RENAME TO idx_channels_tags;
ALTER INDEX IF EXISTS idx_chat_messages_chat RENAME TO idx_messages_channel;
-- Rename constraints (PK and FK auto-renamed with table on some PG versions,
-- but let's be explicit for the FK)
-- Note: PG auto-renames PKs but not FKs or check constraints
-- Rename the updated_at trigger
DROP TRIGGER IF EXISTS chats_updated_at ON channels;
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- ── 3. Add channel type + description ───────
ALTER TABLE channels ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'direct';
ALTER TABLE channels ADD COLUMN IF NOT EXISTS description TEXT;
COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, channel=named persistent';
-- Backfill: all existing rows are 1:1 AI chats
UPDATE channels SET type = 'direct' WHERE type IS NULL;
-- Index on type for filtered queries
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
-- ── 4. Add message tree (parent_id) ─────────
ALTER TABLE messages ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES messages(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
-- Backfill linear parent chains on existing messages.
-- Each message's parent is the previous message in the same channel (by created_at).
WITH ordered AS (
SELECT id, channel_id, created_at,
LAG(id) OVER (PARTITION BY channel_id ORDER BY created_at) AS prev_id
FROM messages
)
UPDATE messages m
SET parent_id = o.prev_id
FROM ordered o
WHERE m.id = o.id AND o.prev_id IS NOT NULL AND m.parent_id IS NULL;
-- ── 5. Add participant columns on messages ──
-- Decouples messages from user-only: AI models are participants too.
ALTER TABLE messages ADD COLUMN IF NOT EXISTS participant_type VARCHAR(10) DEFAULT 'user';
ALTER TABLE messages ADD COLUMN IF NOT EXISTS participant_id VARCHAR(255);
COMMENT ON COLUMN messages.participant_type IS 'user or model';
COMMENT ON COLUMN messages.participant_id IS 'user UUID or model identifier string';
-- Backfill: user messages get the channel owner's user_id;
-- assistant messages get the model name as participant_id.
UPDATE messages m
SET participant_type = CASE WHEN m.role = 'assistant' THEN 'model' ELSE 'user' END,
participant_id = CASE
WHEN m.role = 'assistant' THEN COALESCE(m.model, 'unknown')
ELSE (SELECT c.user_id::text FROM channels c WHERE c.id = m.channel_id)
END
WHERE m.participant_id IS NULL;
-- ── 6. Update model_routing_log FK ──────────
-- The FK column was named chat_id — rename it
ALTER TABLE model_routing_log RENAME COLUMN chat_id TO channel_id;
ALTER INDEX IF EXISTS idx_routing_log_chat RENAME TO idx_routing_log_channel;
-- message_id FK still valid (messages table was renamed, FK follows)
-- ── 7. Update tool_usage_log ────────────────
-- We already dropped the old channel_id column above.
-- Re-add it pointing to the unified channels table.
-- Also rename the old chat_id column.
ALTER TABLE tool_usage_log RENAME COLUMN chat_id TO channel_id;
-- The FK auto-follows the table rename, but let's be safe:
-- (chat_id FK pointed to chats(id), which is now channels(id) — PG handles this)

View File

@@ -1,56 +0,0 @@
-- ==========================================
-- Migration 007: Channel Members & Models
-- ==========================================
-- Membership and model assignment tables for
-- multi-user and multi-model channels.
-- ==========================================
-- ── Channel Members ─────────────────────────
-- Who is in this channel (human participants).
CREATE TABLE IF NOT EXISTS channel_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) DEFAULT 'member', -- owner, admin, member
joined_at TIMESTAMPTZ DEFAULT NOW(),
last_read_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX idx_channel_members_channel ON channel_members(channel_id);
CREATE INDEX idx_channel_members_user ON channel_members(user_id);
-- Backfill: existing channels are 1:1, so the channel owner is the sole member.
INSERT INTO channel_members (channel_id, user_id, role)
SELECT id, user_id, 'owner'
FROM channels
WHERE user_id IS NOT NULL
ON CONFLICT (channel_id, user_id) DO NOTHING;
-- ── Channel Models ──────────────────────────
-- Which AI models are assigned to this channel.
-- For direct chats this is one model; for group/channel
-- there can be multiple with @mention routing.
CREATE TABLE IF NOT EXISTS channel_models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
model_id VARCHAR(255) NOT NULL, -- e.g. 'claude-sonnet-4'
api_config_id UUID REFERENCES api_configs(id) ON DELETE SET NULL,
display_name VARCHAR(100), -- optional alias in this channel
system_prompt TEXT, -- per-model system prompt override
settings JSONB DEFAULT '{}'::jsonb, -- temperature, max_tokens overrides
is_default BOOLEAN DEFAULT false, -- auto-complete (no @mention needed)
added_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, model_id)
);
CREATE INDEX idx_channel_models_channel ON channel_models(channel_id);
-- Backfill: existing channels have a model in the channels.model column.
INSERT INTO channel_models (channel_id, model_id, api_config_id, is_default)
SELECT id, model, api_config_id, true
FROM channels
WHERE model IS NOT NULL AND model != ''
ON CONFLICT (channel_id, model_id) DO NOTHING;

View File

@@ -1,29 +0,0 @@
-- ==========================================
-- Migration 008: Channel Cursors
-- ==========================================
-- Tracks each user's active branch position
-- per channel. Essential for conversation forking.
-- ==========================================
CREATE TABLE IF NOT EXISTS channel_cursors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
active_leaf_id UUID REFERENCES messages(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX idx_channel_cursors_channel ON channel_cursors(channel_id);
CREATE INDEX idx_channel_cursors_user ON channel_cursors(user_id);
-- Backfill: set cursor to the last message in each channel for the owner.
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
SELECT c.id, c.user_id, (
SELECT m.id FROM messages m
WHERE m.channel_id = c.id
ORDER BY m.created_at DESC LIMIT 1
)
FROM channels c
WHERE c.user_id IS NOT NULL
ON CONFLICT (channel_id, user_id) DO NOTHING;

View File

@@ -1,57 +0,0 @@
-- ==========================================
-- Migration 009: Folders & Projects
-- ==========================================
-- Organizational structures for channels.
-- Folders are simple containers; projects are
-- tagged collections that can span folders.
-- ==========================================
-- ── Folders ─────────────────────────────────
CREATE TABLE IF NOT EXISTS folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, name, parent_id)
);
CREATE INDEX idx_folders_user ON folders(user_id);
CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Add folder_id to channels (replaces the old text folder column)
ALTER TABLE channels ADD COLUMN IF NOT EXISTS folder_id UUID REFERENCES folders(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id);
-- ── Projects ────────────────────────────────
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
color VARCHAR(7), -- hex color for UI badge
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_projects_user ON projects(user_id);
CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Junction: channels can belong to multiple projects
CREATE TABLE IF NOT EXISTS project_channels (
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (project_id, channel_id)
);
CREATE INDEX idx_project_channels_channel ON project_channels(channel_id);

View File

@@ -1,32 +0,0 @@
-- ==========================================
-- Migration 010: Environment Banners
-- ==========================================
-- Environment banners for deployment context
-- (dev, staging, production, etc). Stored in
-- global_settings with a dedicated key.
-- ==========================================
-- Seed default banner config (disabled).
-- Schema: { enabled, text, position, bg, fg }
INSERT INTO global_settings (key, value) VALUES
('banner', '{
"enabled": false,
"text": "",
"position": "both",
"bg": "#007a33",
"fg": "#ffffff"
}'::jsonb)
ON CONFLICT (key) DO NOTHING;
-- Banner presets for quick selection in admin UI.
-- Generic environment labels — admins can set custom text.
INSERT INTO global_settings (key, value) VALUES
('banner_presets', '{
"development": { "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff" },
"testing": { "text": "TESTING", "bg": "#502b85", "fg": "#ffffff" },
"staging": { "text": "STAGING", "bg": "#0033a0", "fg": "#ffffff" },
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
"demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" }
}'::jsonb)
ON CONFLICT (key) DO NOTHING;

View File

@@ -1,18 +0,0 @@
-- ==========================================
-- Migration 011: Replace banner presets
-- ==========================================
-- Replaces legacy presets with
-- generic environment labels. Existing databases
-- that ran 010 have the old presets; this overwrites.
-- ==========================================
UPDATE global_settings
SET value = '{
"development": { "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff" },
"testing": { "text": "TESTING", "bg": "#502b85", "fg": "#ffffff" },
"staging": { "text": "STAGING", "bg": "#0033a0", "fg": "#ffffff" },
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
"demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" }
}'::jsonb
WHERE key = 'banner_presets';

View File

@@ -1,33 +0,0 @@
-- Model Presets: named wrappers around base models with bundled config.
-- Admins create org-wide presets, users create personal ones.
CREATE TABLE IF NOT EXISTS model_presets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
description TEXT DEFAULT '',
base_model_id TEXT NOT NULL, -- e.g. "gpt-4o", "claude-sonnet-4-20250514"
api_config_id UUID REFERENCES api_configs(id) ON DELETE CASCADE,
system_prompt TEXT DEFAULT '',
temperature REAL, -- NULL = use model default
max_tokens INTEGER, -- NULL = use model default
tools_enabled JSONB DEFAULT '[]'::jsonb, -- reserved for future tool framework
scope VARCHAR(20) NOT NULL DEFAULT 'personal'
CHECK (scope IN ('global', 'team', 'personal')),
team_id UUID, -- nullable; for future team scoping
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
is_shared BOOLEAN DEFAULT false, -- personal presets visible to others
is_active BOOLEAN DEFAULT true,
icon VARCHAR(10) DEFAULT '', -- emoji or short icon code
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_model_presets_scope ON model_presets(scope);
CREATE INDEX IF NOT EXISTS idx_model_presets_created_by ON model_presets(created_by);
CREATE INDEX IF NOT EXISTS idx_model_presets_team ON model_presets(team_id) WHERE team_id IS NOT NULL;
COMMENT ON TABLE model_presets IS 'Named model configurations: org-wide or personal wrappers around base models';
COMMENT ON COLUMN model_presets.base_model_id IS 'The underlying model_id (matches model_configs.model_id)';
COMMENT ON COLUMN model_presets.api_config_id IS 'Which provider config to use (NULL = resolve at completion time)';
COMMENT ON COLUMN model_presets.scope IS 'global = admin-created for all users; team = team-scoped; personal = user-created';
COMMENT ON COLUMN model_presets.tools_enabled IS 'JSON array of tool names enabled for this preset (future use)';

View File

@@ -1,28 +0,0 @@
-- ==========================================
-- Migration 013: Message Forking Support
-- ==========================================
-- Adds soft delete and sibling ordering to support
-- conversation forking (edit, regenerate, branch).
-- See ARCHITECTURE.md §8 for the full tree model.
-- ==========================================
-- Soft delete: pruned branches keep their structure for undo
ALTER TABLE messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- Sibling ordering: explicit position among children of same parent
-- First child = 0, second = 1, etc. Set at insert time.
ALTER TABLE messages ADD COLUMN IF NOT EXISTS sibling_index INTEGER DEFAULT 0;
-- Partial index: fast lookup of live messages only
CREATE INDEX IF NOT EXISTS idx_messages_alive
ON messages(channel_id, created_at)
WHERE deleted_at IS NULL;
-- Children of a parent (for sibling queries)
CREATE INDEX IF NOT EXISTS idx_messages_parent_alive
ON messages(parent_id, sibling_index)
WHERE deleted_at IS NULL;
-- Backfill sibling_index for existing messages.
-- In linear conversations every message is the sole child of its parent,
-- so all get sibling_index = 0 (already the default). No update needed.

View File

@@ -1,50 +0,0 @@
-- 014_notes.sql — Notes table with full-text search
--
-- Notes are user-scoped persistent documents. The LLM can create, search,
-- and update them via built-in tools (note_create, note_search, etc.).
-- Full-text search uses PostgreSQL tsvector — zero additional infrastructure.
--
-- DROP first: if a previous deployment created an incomplete version of
-- this table (e.g. missing search_vector), IF NOT EXISTS would skip and
-- the indexes/trigger would fail. Safe because 014 was never recorded
-- in schema_migrations — any existing data is from a failed attempt.
DROP TABLE IF EXISTS notes CASCADE;
CREATE TABLE notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content TEXT DEFAULT '',
folder_path TEXT DEFAULT '/',
tags TEXT[] DEFAULT '{}',
metadata JSONB DEFAULT '{}',
source_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
search_vector TSVECTOR,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_notes_user ON notes(user_id);
CREATE INDEX idx_notes_search ON notes USING GIN(search_vector);
CREATE INDEX idx_notes_folder ON notes(user_id, folder_path);
CREATE INDEX idx_notes_tags ON notes USING GIN(tags);
CREATE INDEX idx_notes_updated ON notes(user_id, updated_at DESC);
-- Auto-update search vector from title + content
CREATE OR REPLACE FUNCTION notes_search_update_fn()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.content, '')), 'B');
NEW.updated_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop first to allow re-run
DROP TRIGGER IF EXISTS notes_search_update ON notes;
CREATE TRIGGER notes_search_update
BEFORE INSERT OR UPDATE OF title, content ON notes
FOR EACH ROW EXECUTE FUNCTION notes_search_update_fn();

View File

@@ -1,6 +0,0 @@
-- Avatar support for model presets.
-- Users table already has avatar_url from 001_full_schema.sql.
ALTER TABLE model_presets ADD COLUMN IF NOT EXISTS avatar TEXT DEFAULT '';
COMMENT ON COLUMN model_presets.avatar IS 'Base64 data URI of preset avatar image (128x128 PNG), empty = use icon emoji';

View File

@@ -1,76 +0,0 @@
-- ==========================================
-- Migration 016: Teams
-- ==========================================
-- Teams are the middle tier between system admin and individual users.
-- A team admin can manage members, create team-scoped presets,
-- and enforce provider policies — without system-wide access.
-- ==========================================
-- ── 1. Teams table ──────────────────────────
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL UNIQUE,
description TEXT DEFAULT '',
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb, -- team-level policies
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_teams_active ON teams(is_active) WHERE is_active = true;
CREATE TRIGGER teams_updated_at BEFORE UPDATE ON teams
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON TABLE teams IS 'Organizational teams with scoped administration';
COMMENT ON COLUMN teams.settings IS 'Team policies: {"require_private_providers": false}';
-- ── 2. Team Members table ───────────────────
CREATE TABLE IF NOT EXISTS team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member'
CHECK (role IN ('admin', 'member')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(team_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members(team_id);
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
COMMENT ON TABLE team_members IS 'Team membership with per-team roles (admin or member)';
COMMENT ON COLUMN team_members.role IS 'admin = manages team; member = uses team resources';
-- ── 3. Wire model_presets.team_id FK ────────
-- Column already exists (migration 012), just add the FK constraint.
DO $$ BEGIN
ALTER TABLE model_presets
ADD CONSTRAINT fk_presets_team
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- ── 4. Add team_id to channels ──────────────
-- Team channels are visible to all team members.
ALTER TABLE channels ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id) WHERE team_id IS NOT NULL;
-- ── 5. Private provider flag ────────────────
-- Marks a provider as local/self-hosted (data stays on-prem).
ALTER TABLE api_configs ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT false;
COMMENT ON COLUMN api_configs.is_private IS 'Private/self-hosted provider — data does not leave network';
-- ── 6. Add team_id to notes ─────────────────
-- Team notes are shared within the team.
ALTER TABLE notes ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_notes_team ON notes(team_id) WHERE team_id IS NOT NULL;

View File

@@ -1,37 +0,0 @@
-- ==========================================
-- Migration 017: Audit Log
-- ==========================================
-- Immutable append-only log of all mutating actions.
-- Required for enterprise compliance (SOC2, FedRAMP, HIPAA).
-- ==========================================
-- Drop stale table if left from a prior partial run
DROP TABLE IF EXISTS audit_log CASCADE;
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL, -- e.g. 'user.create', 'team.add_member'
resource_type VARCHAR(50) NOT NULL, -- e.g. 'user', 'team', 'preset', 'channel'
resource_id VARCHAR(255), -- UUID or identifier of affected resource
metadata JSONB DEFAULT '{}'::jsonb, -- action-specific details
ip_address VARCHAR(45), -- IPv4 or IPv6
user_agent TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Time-range queries (admin viewer, compliance exports)
CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC);
-- Filter by actor
CREATE INDEX idx_audit_log_actor ON audit_log(actor_id) WHERE actor_id IS NOT NULL;
-- Filter by resource
CREATE INDEX idx_audit_log_resource ON audit_log(resource_type, resource_id);
-- Filter by action
CREATE INDEX idx_audit_log_action ON audit_log(action);
COMMENT ON TABLE audit_log IS 'Immutable audit trail of all mutating operations';
COMMENT ON COLUMN audit_log.action IS 'Dotted action name: resource.verb (e.g. user.create, team.add_member)';
COMMENT ON COLUMN audit_log.metadata IS 'Action-specific context: old/new values, affected fields, etc.';

View File

@@ -1,36 +0,0 @@
-- ==========================================
-- Migration 018: Model Visibility
-- ==========================================
-- Replace binary is_enabled with three-state visibility:
-- 'enabled' — visible to all users in model selector
-- 'disabled' — hidden from everyone
-- 'team' — only available to team admins for building presets
-- ==========================================
-- Add new column (idempotent)
ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS visibility VARCHAR(10) DEFAULT 'disabled';
-- Backfill from is_enabled if it still exists
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'model_configs' AND column_name = 'is_enabled'
) THEN
UPDATE model_configs SET visibility = CASE
WHEN is_enabled = true THEN 'enabled'
ELSE 'disabled'
END;
ALTER TABLE model_configs DROP COLUMN is_enabled;
END IF;
END $$;
-- Ensure NOT NULL (safe: DEFAULT already covers new rows)
UPDATE model_configs SET visibility = 'disabled' WHERE visibility IS NULL;
ALTER TABLE model_configs ALTER COLUMN visibility SET NOT NULL;
-- Constraint (drop first for idempotency)
ALTER TABLE model_configs DROP CONSTRAINT IF EXISTS chk_model_visibility;
ALTER TABLE model_configs ADD CONSTRAINT chk_model_visibility
CHECK (visibility IN ('enabled', 'disabled', 'team'));
COMMENT ON COLUMN model_configs.visibility IS 'enabled=all users, team=team admin presets only, disabled=hidden';

View File

@@ -1,8 +0,0 @@
-- ==========================================
-- Migration 019: (superseded by 020)
-- ==========================================
-- Original CREATE TABLE IF NOT EXISTS was a no-op because
-- user_model_preferences already existed from migration 005.
-- The actual rework is in 020_user_model_preferences_rework.sql.
-- ==========================================
SELECT 1;

View File

@@ -1,24 +0,0 @@
-- ==========================================
-- Migration 019: User Model Preferences (rework)
-- ==========================================
-- The user_model_preferences table was created in 005 with:
-- id UUID PK, user_id UUID, model_config_id UUID FK, is_enabled BOOL
-- That schema ties preferences to model_configs rows (global only).
-- We need string-based model_id to support personal provider models too,
-- plus a 'hidden' column with clearer semantics.
--
-- Strategy: add new columns, add unique constraint for UPSERT.
-- Old columns (model_config_id, is_enabled) remain for backward compat.
-- ==========================================
-- Add new columns if they don't exist
ALTER TABLE user_model_preferences ADD COLUMN IF NOT EXISTS model_id VARCHAR(255);
ALTER TABLE user_model_preferences ADD COLUMN IF NOT EXISTS hidden BOOLEAN DEFAULT false;
ALTER TABLE user_model_preferences ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();
-- Make model_config_id nullable (new rows use model_id instead)
ALTER TABLE user_model_preferences ALTER COLUMN model_config_id DROP NOT NULL;
-- Unique constraint for UPSERT — NULLs are distinct in PG so old rows won't conflict
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_model_pref_user_model
ON user_model_preferences (user_id, model_id);

View File

@@ -1,16 +0,0 @@
-- ==========================================
-- Migration 021: Team Providers
-- ==========================================
-- Adds team_id to api_configs, enabling teams to have their own
-- provider configs managed by team admins.
--
-- Provider hierarchy: global (user_id IS NULL, is_global=true)
-- → team (team_id IS NOT NULL)
-- → personal (user_id IS NOT NULL)
-- ==========================================
-- Add team_id FK to api_configs
ALTER TABLE api_configs ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_api_configs_team ON api_configs(team_id) WHERE team_id IS NOT NULL;
COMMENT ON COLUMN api_configs.team_id IS 'Team-scoped provider — managed by team admins, visible to team members';

View File

@@ -68,8 +68,6 @@ func SetupTestDB() func() {
createdByUs := false
adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName))
if _, err := adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s", testDBName)); err != nil {
// Permission denied is expected in CI — the bootstrap step
// already created the DB with admin creds. Just proceed.
log.Printf("⚠ Cannot CREATE DATABASE %s (will try to connect to existing): %v", testDBName, err)
} else {
createdByUs = true
@@ -161,14 +159,19 @@ func TruncateAll(t *testing.T) {
// Order matters due to foreign keys — truncate with CASCADE
tables := []string{
"notes",
"audit_log",
"channel_cursors",
"messages",
"channel_models",
"channel_members",
"channels",
"model_configs",
"model_presets",
"api_configs",
"user_model_settings",
"model_catalog",
"persona_grants",
"personas",
"provider_configs",
"team_members",
"teams",
"refresh_tokens",
"users",
}
@@ -197,16 +200,13 @@ func SeedTestChannel(t *testing.T, userID, title string) string {
t.Helper()
var id string
err := DB.QueryRow(`
INSERT INTO channels (title, created_by)
INSERT INTO channels (user_id, title)
VALUES ($1, $2)
RETURNING id
`, title, userID).Scan(&id)
`, userID, title).Scan(&id)
if err != nil {
t.Fatalf("SeedTestChannel: %v", err)
}
// Add ownership
DB.Exec(`INSERT INTO channel_members (channel_id, user_id, role) VALUES ($1, $2, 'owner')`,
id, userID)
return id
}
@@ -233,7 +233,6 @@ func replaceDBName(dsn, newDB string) string {
}
// Handle URL format: postgres://user:pass@host/olddb?...
if strings.Contains(dsn, "://") {
// Find the last / before ? and replace the path
idx := strings.LastIndex(dsn, "/")
qIdx := strings.Index(dsn, "?")
if qIdx > idx {

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +0,0 @@
package handlers
import (
"testing"
)
func TestNewSettingsHandler(t *testing.T) {
h := NewSettingsHandler()
if h == nil {
t.Fatal("NewSettingsHandler returned nil")
}
}
func TestNewAdminHandler(t *testing.T) {
h := NewAdminHandler()
if h == nil {
t.Fatal("NewAdminHandler returned nil")
}
}
func TestIsRegistrationEnabledDefaultsTrue(t *testing.T) {
// Without a database connection, should default to true
enabled := IsRegistrationEnabled()
if !enabled {
t.Error("Expected registration enabled by default when no DB")
}
}

View File

@@ -1,646 +1,243 @@
package handlers
import (
"database/sql"
"encoding/json"
"log"
"math"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Request / Response Types ────────────────
type createAPIConfigRequest struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required"`
Endpoint string `json:"endpoint" binding:"required"`
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
IsPrivate bool `json:"is_private,omitempty"`
// ProviderConfigHandler handles user-facing provider config endpoints.
type ProviderConfigHandler struct {
stores store.Stores
}
type updateAPIConfigRequest struct {
Name *string `json:"name,omitempty"`
Endpoint *string `json:"endpoint,omitempty"`
APIKey *string `json:"api_key,omitempty"`
ModelDefault *string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"`
func NewProviderConfigHandler(s store.Stores) *ProviderConfigHandler {
return &ProviderConfigHandler{stores: s}
}
type apiConfigResponse struct {
ID string `json:"id"`
UserID *string `json:"user_id"`
Name string `json:"name"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
HasKey bool `json:"has_key"` // Never expose the actual key
ModelDefault *string `json:"model_default"`
Config map[string]interface{} `json:"config"`
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// APIConfigHandler holds dependencies.
type APIConfigHandler struct{}
// NewAPIConfigHandler creates a new handler.
func NewAPIConfigHandler() *APIConfigHandler {
return &APIConfigHandler{}
}
// ── List API Configs ────────────────────────
func (h *APIConfigHandler) ListConfigs(c *gin.Context) {
// ListConfigs returns configs accessible to the user (global + personal + team).
func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) {
userID := getUserID(c)
page, perPage, offset := parsePagination(c)
// Count: user's configs + global configs (exclude team-scoped)
var total int
err := database.DB.QueryRow(
`SELECT COUNT(*) FROM api_configs WHERE (user_id = $1 OR user_id IS NULL) AND team_id IS NULL`,
userID,
).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count configs"})
return
}
rows, err := database.DB.Query(`
SELECT id, user_id, name, provider, endpoint, api_key_encrypted,
model_default, config, is_active, created_at, updated_at
FROM api_configs
WHERE (user_id = $1 OR user_id IS NULL) AND team_id IS NULL
ORDER BY user_id NULLS LAST, name ASC
LIMIT $2 OFFSET $3
`, userID, perPage, offset)
// User settings → Providers shows only personal (BYOK) configs.
// Global/team providers are managed by admins and surfaced via the model list.
cfgs, err := h.stores.Providers.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
return
}
defer rows.Close()
configs := make([]apiConfigResponse, 0)
for rows.Next() {
cfg, err := scanAPIConfig(rows)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan config"})
return
// Mask API keys
type safeConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
HasKey bool `json:"has_key"`
ModelDefault string `json:"model_default,omitempty"`
Scope string `json:"scope"`
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
out := make([]safeConfig, len(cfgs))
for i, cfg := range cfgs {
out[i] = safeConfig{
ID: cfg.ID,
Name: cfg.Name,
Provider: cfg.Provider,
Endpoint: cfg.Endpoint,
HasKey: cfg.APIKeyEnc != "",
ModelDefault: cfg.ModelDefault,
Scope: cfg.Scope,
IsActive: cfg.IsActive,
CreatedAt: cfg.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
configs = append(configs, cfg)
}
c.JSON(http.StatusOK, paginatedResponse{
Data: configs,
Page: page,
PerPage: perPage,
Total: total,
TotalPages: int(math.Ceil(float64(total) / float64(perPage))),
})
c.JSON(http.StatusOK, gin.H{"configs": out})
}
// ── Create API Config ───────────────────────
func (h *APIConfigHandler) CreateConfig(c *gin.Context) {
// GetConfig returns a single config by ID (if user has access).
func (h *ProviderConfigHandler) GetConfig(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
var req createAPIConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate provider exists
if _, err := providers.Get(req.Provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "unsupported provider: " + req.Provider,
"supported_providers": providers.List(),
})
return
}
configJSON := "{}"
if req.Config != nil {
b, _ := json.Marshal(req.Config)
configJSON = string(b)
}
var cfg apiConfigResponse
var apiKeyEnc *string
var configRaw string
err := database.DB.QueryRow(`
INSERT INTO api_configs (user_id, name, provider, endpoint, api_key_encrypted, model_default, config)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
RETURNING id, user_id, name, provider, endpoint, api_key_encrypted,
model_default, config::text, is_active, created_at, updated_at
`, userID, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault, configJSON,
).Scan(
&cfg.ID, &cfg.UserID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, &apiKeyEnc,
&cfg.ModelDefault, &configRaw, &cfg.IsActive, &cfg.CreatedAt, &cfg.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
return
}
cfg.HasKey = apiKeyEnc != nil && *apiKeyEnc != ""
cfg.Config = parseJSONBConfig(configRaw)
c.JSON(http.StatusCreated, cfg)
}
// ── Get API Config ──────────────────────────
func (h *APIConfigHandler) GetConfig(c *gin.Context) {
userID := getUserID(c)
configID := c.Param("id")
row := database.DB.QueryRow(`
SELECT id, user_id, name, provider, endpoint, api_key_encrypted,
model_default, config::text, is_active, created_at, updated_at
FROM api_configs
WHERE id = $1 AND (user_id = $2 OR user_id IS NULL) AND team_id IS NULL
`, configID, userID)
var cfg apiConfigResponse
var apiKeyEnc *string
var configRaw string
err := row.Scan(
&cfg.ID, &cfg.UserID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, &apiKeyEnc,
&cfg.ModelDefault, &configRaw, &cfg.IsActive, &cfg.CreatedAt, &cfg.UpdatedAt,
)
if err == sql.ErrNoRows {
if ok, _ := h.stores.Providers.UserCanAccess(c.Request.Context(), userID, id); !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
}
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get config"})
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
}
cfg.HasKey = apiKeyEnc != nil && *apiKeyEnc != ""
cfg.Config = parseJSONBConfig(configRaw)
c.JSON(http.StatusOK, cfg)
}
// ── Update API Config ───────────────────────
func (h *APIConfigHandler) UpdateConfig(c *gin.Context) {
// CreateConfig creates a personal provider config (BYOK).
// After creation, automatically fetches models from the provider API and enables them.
func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) {
userID := getUserID(c)
configID := c.Param("id")
var req updateAPIConfigRequest
// Check policy
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_byok")
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "personal API keys not allowed"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
Provider string `json:"provider" binding:"required"`
Endpoint string `json:"endpoint" binding:"required"`
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify ownership (only owner can update, not global)
var ownerID *string
err := database.DB.QueryRow(`SELECT user_id FROM api_configs WHERE id = $1`, configID).Scan(&ownerID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
cfg := &models.ProviderConfig{
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
APIKeyEnc: req.APIKey, // TODO: encrypt
ModelDefault: req.ModelDefault,
Scope: models.ScopePersonal,
OwnerID: &userID,
IsActive: true,
}
if ownerID == nil || *ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify global or other user's config"})
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
return
}
// Dynamic update
setClauses := []string{}
args := []interface{}{}
argN := 1
addClause := func(col string, val interface{}) {
setClauses = append(setClauses, col+" = $"+strconv.Itoa(argN))
args = append(args, val)
argN++
// Auto-fetch models from the provider API and enable them.
// The user added this key to USE it — don't make them hunt for a fetch button.
resp := gin.H{
"id": cfg.ID,
"name": cfg.Name,
"provider": cfg.Provider,
"endpoint": cfg.Endpoint,
"scope": cfg.Scope,
}
if req.Name != nil {
addClause("name", *req.Name)
}
if req.Endpoint != nil {
addClause("endpoint", *req.Endpoint)
}
if req.APIKey != nil {
addClause("api_key_encrypted", *req.APIKey)
}
if req.ModelDefault != nil {
addClause("model_default", *req.ModelDefault)
}
if req.Config != nil {
b, _ := json.Marshal(req.Config)
addClause("config", string(b))
}
if req.IsActive != nil {
addClause("is_active", *req.IsActive)
}
if len(setClauses) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
query := "UPDATE api_configs SET updated_at = NOW(), "
for i, clause := range setClauses {
if i > 0 {
query += ", "
}
query += clause
}
query += " WHERE id = $" + strconv.Itoa(argN)
args = append(args, configID)
_, err = database.DB.Exec(query, args...)
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg)
if err != nil {
// Provider created successfully but model fetch failed.
// Return 201 (provider exists) with a warning so the frontend can show it.
resp["warning"] = "Provider created but model fetch failed: " + err.Error()
resp["models_fetched"] = 0
log.Printf("warn: BYOK auto-fetch for %s (%s) failed: %v", cfg.ID, cfg.Provider, err)
} else {
resp["models_fetched"] = result.Total
}
c.JSON(http.StatusCreated, resp)
}
// UpdateConfig updates a personal provider config.
func (h *ProviderConfigHandler) UpdateConfig(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only update your own configs"})
return
}
// Bind standard fields
var req struct {
models.ProviderConfigPatch
APIKey *string `json:"api_key,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
patch := req.ProviderConfigPatch
// Transfer api_key → APIKeyEnc (json:"-" prevents auto-binding)
if req.APIKey != nil && *req.APIKey != "" {
patch.APIKeyEnc = req.APIKey // TODO: encrypt
}
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
return
}
h.GetConfig(c)
c.JSON(http.StatusOK, gin.H{"message": "config updated"})
}
// ── Delete API Config ───────────────────────
func (h *APIConfigHandler) DeleteConfig(c *gin.Context) {
// DeleteConfig deletes a personal provider config.
func (h *ProviderConfigHandler) DeleteConfig(c *gin.Context) {
userID := getUserID(c)
configID := c.Param("id")
id := c.Param("id")
// Only allow deleting own configs
result, err := database.DB.Exec(
`DELETE FROM api_configs WHERE id = $1 AND user_id = $2`,
configID, userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
existing, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only delete your own configs"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found or cannot delete global config"})
h.stores.Catalog.DeleteForProvider(c.Request.Context(), id)
if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "config deleted"})
}
// ── List Models from a Config ───────────────
// ListModels returns models for a specific provider config.
func (h *ProviderConfigHandler) ListModels(c *gin.Context) {
id := c.Param("id")
func (h *APIConfigHandler) ListModels(c *gin.Context) {
entries, err := h.stores.Catalog.ListForProvider(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return
}
c.JSON(http.StatusOK, gin.H{"models": entries})
}
// FetchModels fetches models from the provider API and auto-enables them.
// This is the user-facing equivalent of admin POST /models/fetch.
// Allows refreshing models for existing BYOK providers.
func (h *ProviderConfigHandler) FetchModels(c *gin.Context) {
userID := getUserID(c)
configID := c.Param("id")
id := c.Param("id")
// Load config including API key
var providerID, endpoint string
var apiKey *string
err := database.DB.QueryRow(`
SELECT provider, endpoint, api_key_encrypted
FROM api_configs
WHERE id = $1 AND (user_id = $2 OR user_id IS NULL) AND team_id IS NULL AND is_active = true
`, configID, userID).Scan(&providerID, &endpoint, &apiKey)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load config"})
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || cfg.Scope != models.ScopePersonal || cfg.OwnerID == nil || *cfg.OwnerID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
provider, err := providers.Get(providerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
key := ""
if apiKey != nil {
key = *apiKey
}
models, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
Endpoint: endpoint,
APIKey: key,
})
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"config_id": configID,
"provider": providerID,
"models": models,
"message": "models synced",
"added": result.Added,
"updated": result.Updated,
"total": result.Total,
})
}
// ── List All Available Models (aggregate) ───
func (h *APIConfigHandler) ListAllModels(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.Query(`
SELECT id, name, provider, endpoint, api_key_encrypted
FROM api_configs
WHERE (user_id = $1 OR user_id IS NULL) AND is_active = true AND team_id IS NULL
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
return
}
defer rows.Close()
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
OwnedBy string `json:"owned_by,omitempty"`
ConfigID string `json:"config_id"`
Provider string `json:"provider"`
Capabilities providers.ModelCapabilities `json:"capabilities"`
}
allModels := make([]modelEntry, 0)
for rows.Next() {
var cfgID, name, providerID, endpoint string
var apiKey *string
if err := rows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey); err != nil {
continue
}
provider, err := providers.Get(providerID)
if err != nil {
continue
}
key := ""
if apiKey != nil {
key = *apiKey
}
models, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
Endpoint: endpoint,
APIKey: key,
})
if err != nil {
continue // Skip configs that fail
}
for _, m := range models {
// Provider caps are authoritative; fill gaps from known table
caps := providers.MergeCapabilities(m.Capabilities, m.ID)
caps.MaxOutputTokens = providers.ResolveMaxOutput(m.ID, caps)
allModels = append(allModels, modelEntry{
ID: m.ID,
Name: m.Name,
OwnedBy: m.OwnedBy,
ConfigID: cfgID,
Provider: providerID,
Capabilities: caps,
})
}
}
c.JSON(http.StatusOK, gin.H{"models": allModels})
}
// ── List Enabled Models (from model_configs) ─
// enabledModel is the unified model entry returned by ListEnabledModels.
// Used across apiconfigs, capabilities, and preset resolution.
type enabledModel struct {
ID string `json:"id"`
ModelID string `json:"model_id"`
DisplayName *string `json:"display_name"`
Provider string `json:"provider"`
ProviderName string `json:"provider_name"`
ConfigID string `json:"config_id"`
Capabilities providers.ModelCapabilities `json:"capabilities"`
Pricing *providers.ModelPricing `json:"pricing,omitempty"`
Source string `json:"source,omitempty"`
TeamName string `json:"team_name,omitempty"`
IsPreset bool `json:"is_preset,omitempty"`
PresetID string `json:"preset_id,omitempty"`
PresetScope string `json:"preset_scope,omitempty"`
PresetAvatar string `json:"preset_avatar,omitempty"`
PresetTeamName string `json:"preset_team_name,omitempty"`
}
func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
userID := getUserID(c)
models := make([]enabledModel, 0)
// ── 1. Admin model_configs (pre-synced via FetchModels) ──
rows, err := database.DB.Query(`
SELECT mc.id, mc.model_id, mc.display_name, ac.provider, ac.name, mc.api_config_id, mc.capabilities
FROM model_configs mc
JOIN api_configs ac ON mc.api_config_id = ac.id
WHERE mc.visibility = 'enabled' AND ac.is_active = true AND ac.is_global = true
ORDER BY ac.name, mc.model_id
`)
if err == nil {
defer rows.Close()
for rows.Next() {
var m enabledModel
var capsJSON []byte
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Provider, &m.ProviderName, &m.ConfigID, &capsJSON); err != nil {
continue
}
// Parse DB capabilities (from provider at sync time)
var dbCaps providers.ModelCapabilities
_ = json.Unmarshal(capsJSON, &dbCaps)
// Provider-reported caps are authoritative; fill gaps from known table/heuristics
if dbCaps.HasProviderData() {
m.Capabilities = providers.MergeCapabilities(dbCaps, m.ModelID)
} else {
// No provider data — use known table or heuristics as base
knownCaps, found := providers.LookupKnownModel(m.ModelID)
if !found {
knownCaps = providers.InferCapabilities(m.ModelID)
}
m.Capabilities = knownCaps
}
m.Capabilities.MaxOutputTokens = providers.ResolveMaxOutput(m.ModelID, m.Capabilities)
m.Source = "global"
models = append(models, m)
}
}
// ── 2. User provider models (live query) ──
// NOTE: Team provider models are NOT listed here. They are only
// available to team admins for building presets (via ListAvailableModels).
// Team members access team models through curated presets only.
userRows, err := database.DB.Query(`
SELECT id, name, provider, endpoint, api_key_encrypted, custom_headers
FROM api_configs
WHERE user_id = $1 AND is_active = true AND team_id IS NULL
`, userID)
if err == nil {
defer userRows.Close()
for userRows.Next() {
var cfgID, name, providerID, endpoint string
var apiKey *string
var headersJSON []byte
if err := userRows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey, &headersJSON); err != nil {
continue
}
provider, err := providers.Get(providerID)
if err != nil {
continue
}
key := ""
if apiKey != nil {
key = *apiKey
}
var customHeaders map[string]string
_ = json.Unmarshal(headersJSON, &customHeaders)
provModels, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
Endpoint: endpoint,
APIKey: key,
CustomHeaders: customHeaders,
})
if err != nil {
log.Printf("[models] user provider %q (%s) list failed: %v", name, providerID, err)
continue
}
for _, pm := range provModels {
caps := pm.Capabilities
// Provider-reported caps are authoritative; fill gaps
caps = providers.MergeCapabilities(caps, pm.ID)
caps.MaxOutputTokens = providers.ResolveMaxOutput(pm.ID, caps)
models = append(models, enabledModel{
ID: pm.ID,
ModelID: pm.ID,
Provider: providerID,
ProviderName: name,
ConfigID: cfgID,
Capabilities: caps,
Pricing: pm.Pricing,
Source: "personal",
})
}
}
}
// ── 3. Active presets (global + user's team + user's personal + shared) ──
presetRows, err := database.DB.Query(`
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
mp.icon, mp.avatar, mp.scope, mp.temperature, mp.max_tokens,
COALESCE(ac.provider, '') as provider, COALESCE(ac.name, '') as provider_name,
COALESCE(t.name, '') as team_name
FROM model_presets mp
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
LEFT JOIN teams t ON mp.team_id = t.id
WHERE mp.is_active = true
AND (
mp.scope = 'global'
OR (mp.scope = 'personal' AND mp.created_by = $1)
OR (mp.scope = 'personal' AND mp.is_shared = true)
OR (mp.scope = 'team' AND mp.team_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
)
ORDER BY mp.scope ASC, mp.name ASC
`, userID)
if err == nil {
defer presetRows.Close()
for presetRows.Next() {
var presetID, name, description, baseModelID, icon, avatar, scope, provID, provName, teamName string
var apiConfigID *string
var temp *float64
var maxTok *int
if err := presetRows.Scan(&presetID, &name, &description, &baseModelID, &apiConfigID,
&icon, &avatar, &scope, &temp, &maxTok, &provID, &provName, &teamName); err != nil {
continue
}
// Inherit capabilities from base model via shared resolver
cfgID := ""
if apiConfigID != nil {
cfgID = *apiConfigID
}
caps := ResolveModelCapsFromLoaded(c, baseModelID, cfgID, models)
// Build display name: "icon Name (base-model)"
displayName := name
if icon != "" {
displayName = icon + " " + name
}
models = append(models, enabledModel{
ID: presetID,
ModelID: baseModelID,
DisplayName: &displayName,
Provider: provID,
ProviderName: provName,
ConfigID: cfgID,
Capabilities: caps,
IsPreset: true,
PresetID: presetID,
PresetScope: scope,
PresetAvatar: avatar,
PresetTeamName: teamName,
})
}
}
c.JSON(http.StatusOK, gin.H{"models": models})
}
// ── Helpers ─────────────────────────────────
type scannable interface {
Scan(dest ...interface{}) error
}
func scanAPIConfig(row scannable) (apiConfigResponse, error) {
var cfg apiConfigResponse
var apiKeyEnc *string
var configRaw string
err := row.Scan(
&cfg.ID, &cfg.UserID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, &apiKeyEnc,
&cfg.ModelDefault, &configRaw, &cfg.IsActive, &cfg.CreatedAt, &cfg.UpdatedAt,
)
if err != nil {
return cfg, err
}
cfg.HasKey = apiKeyEnc != nil && *apiKeyEnc != ""
cfg.Config = parseJSONBConfig(configRaw)
return cfg, nil
}
func parseJSONBConfig(raw string) map[string]interface{} {
result := make(map[string]interface{})
_ = json.Unmarshal([]byte(raw), &result)
return result
}

View File

@@ -1,126 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/providers"
)
func TestCreateConfigMissingFields(t *testing.T) {
h := NewAPIConfigHandler()
r := gin.New()
r.POST("/api-configs", func(c *gin.Context) {
c.Set("user_id", "test-user")
h.CreateConfig(c)
})
tests := []struct {
name string
body string
}{
{"missing name", `{"provider":"openai","endpoint":"http://x"}`},
{"missing provider", `{"name":"Test","endpoint":"http://x"}`},
{"missing endpoint", `{"name":"Test","provider":"openai"}`},
{"empty body", `{}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api-configs",
bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400, got %d (body: %s)", w.Code, w.Body.String())
}
})
}
}
func TestCreateConfigInvalidProvider(t *testing.T) {
h := NewAPIConfigHandler()
r := gin.New()
r.POST("/api-configs", func(c *gin.Context) {
c.Set("user_id", "test-user")
h.CreateConfig(c)
})
body := `{"name":"Test","provider":"nonexistent","endpoint":"http://x"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api-configs",
bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for invalid provider, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if _, ok := resp["supported_providers"]; !ok {
t.Error("Expected supported_providers in error response")
}
}
func TestCompletionHandlerMissingFields(t *testing.T) {
h := NewCompletionHandler()
r := gin.New()
r.POST("/chat/completions", func(c *gin.Context) {
c.Set("user_id", "test-user")
h.Complete(c)
})
tests := []struct {
name string
body string
}{
{"missing channel_id", `{"content":"hello"}`},
{"missing content", `{"channel_id":"abc"}`},
{"empty body", `{}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/chat/completions",
bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400, got %d (body: %s)", w.Code, w.Body.String())
}
})
}
}
func TestSupportedProviders(t *testing.T) {
ids := providers.List()
expected := map[string]bool{
"openai": false,
"anthropic": false,
}
for _, id := range ids {
if _, ok := expected[id]; ok {
expected[id] = true
}
}
for name, found := range expected {
if !found {
t.Errorf("Expected provider %s to be registered", name)
}
}
}

View File

@@ -1,32 +1,28 @@
package handlers
import (
"crypto/rand"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
const (
accessTokenDuration = 15 * time.Minute
refreshTokenDuration = 7 * 24 * time.Hour
bcryptCost = 12
)
// Claims represents the JWT payload.
//
// bcryptCost is shared across auth.go, settings.go, admin.go
const bcryptCost = 12
// Claims is the JWT access token payload.
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
@@ -34,424 +30,261 @@ type Claims struct {
jwt.RegisteredClaims
}
// ── Request / Response types ────────────────
type registerRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8,max=128"`
}
type loginRequest struct {
Login string `json:"login" binding:"required"` // email or username
Password string `json:"password" binding:"required"`
}
type refreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
type authResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"` // seconds
User userResponse `json:"user"`
}
type userResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName *string `json:"display_name"`
Role string `json:"role"`
Avatar *string `json:"avatar,omitempty"`
}
// AuthHandler holds dependencies for auth endpoints.
type AuthHandler struct {
cfg *config.Config
cfg *config.Config
stores store.Stores
}
// NewAuthHandler creates a new auth handler.
func NewAuthHandler(cfg *config.Config) *AuthHandler {
return &AuthHandler{cfg: cfg}
func NewAuthHandler(cfg *config.Config, s store.Stores) *AuthHandler {
return &AuthHandler{cfg: cfg, stores: s}
}
// ── Register ────────────────────────────────
func (h *AuthHandler) Register(c *gin.Context) {
var req registerRequest
var req struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required,min=8"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.Username = strings.TrimSpace(req.Username)
// Check if this is the first user (will become admin)
var userCount int
_ = database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount)
isFirstUser := userCount == 0
// First-user-becomes-admin only when no env admin is configured
envAdminSet := os.Getenv("SWITCHBOARD_ADMIN_USERNAME") != ""
promoteFirst := isFirstUser && !envAdminSet
// If not first user (or env admin handles bootstrap), check registration
if !promoteFirst {
if !IsRegistrationEnabled() {
c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"})
return
}
// Check registration policy
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_registration")
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"})
return
}
// Check duplicate
if existing, _ := h.stores.Users.GetByUsername(c.Request.Context(), req.Username); existing != nil {
c.JSON(http.StatusConflict, gin.H{"error": "username already taken"})
return
}
if existing, _ := h.stores.Users.GetByEmail(c.Request.Context(), req.Email); existing != nil {
c.JSON(http.StatusConflict, gin.H{"error": "email already registered"})
return
}
// Hash password
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
// Determine role and active state
role := "user"
isActive := true
if promoteFirst {
role = "admin"
} else {
// Apply registration default state
if GetRegistrationDefaultState() == "pending" {
isActive = false
}
// Check if user should be active by default
defaultActive, _ := h.stores.Policies.GetBool(c.Request.Context(), "default_user_active")
user := &models.User{
Username: req.Username,
Email: req.Email,
PasswordHash: string(hash),
Role: models.UserRoleUser,
IsActive: defaultActive,
}
// Insert user
var user userResponse
err = database.DB.QueryRow(`
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, display_name, role, avatar_url
`, req.Username, req.Email, string(hash), role, isActive).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.Avatar,
)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
field := "email"
if strings.Contains(err.Error(), "username") {
field = "username"
}
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%s already taken", field)})
return
}
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
return
}
// If account is pending, don't generate tokens
if !isActive {
if !user.IsActive {
c.JSON(http.StatusCreated, gin.H{
"message": "Account created and pending admin approval",
"pending": true,
})
AuditLogWithActor(user.ID, c, "user.register", "user", user.ID, map[string]interface{}{
"username": req.Username, "pending": true,
"message": "Account created but requires admin approval",
"user_id": user.ID,
})
return
}
// Generate tokens
resp, err := h.generateTokenPair(user)
tokens, err := h.generateTokens(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusCreated, resp)
AuditLogWithActor(user.ID, c, "user.register", "user", user.ID, map[string]interface{}{
"username": req.Username, "pending": false,
})
c.JSON(http.StatusCreated, tokens)
}
// IsRegistrationEnabled checks the global_settings table.
// Returns true if the table doesn't exist (pre-migration), DB is nil, or setting is enabled.
func IsRegistrationEnabled() bool {
if database.DB == nil {
return true
func (h *AuthHandler) Login(c *gin.Context) {
var req struct {
Login string `json:"login" binding:"required"` // username or email
Password string `json:"password" binding:"required"`
}
var enabled bool
err := database.DB.QueryRow(`
SELECT COALESCE((value->>'value')::boolean, true)
FROM global_settings WHERE key = 'registration_enabled'
`).Scan(&enabled)
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.stores.Users.GetByLogin(c.Request.Context(), req.Login)
if err != nil {
return true // Default to open if setting missing
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
return enabled
if !user.IsActive {
c.JSON(http.StatusForbidden, gin.H{"error": "account is inactive"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
h.stores.Users.UpdateLastLogin(c.Request.Context(), user.ID)
tokens, err := h.generateTokens(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusOK, tokens)
}
// GetRegistrationDefaultState returns "active" or "pending".
func GetRegistrationDefaultState() string {
if database.DB == nil {
return "active"
func (h *AuthHandler) Refresh(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
var state string
err := database.DB.QueryRow(`
SELECT COALESCE(value->>'value', 'active')
FROM global_settings WHERE key = 'registration_default_state'
`).Scan(&state)
if err != nil || state == "" {
return "active"
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
return state
tokenHash := hashToken(req.RefreshToken)
userID, err := h.stores.Users.GetRefreshToken(c.Request.Context(), tokenHash)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
// Revoke the used token (rotate)
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
if err != nil || !user.IsActive {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
return
}
tokens, err := h.generateTokens(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusOK, tokens)
}
// BootstrapAdmin creates or updates the admin user from environment variables.
// This runs on every startup, so changing the K8s secret + restarting resets the password.
// Handles both username and email conflicts (e.g. admin username changed between deploys).
func BootstrapAdmin(cfg *config.Config) {
func (h *AuthHandler) Logout(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
c.ShouldBindJSON(&req)
if req.RefreshToken != "" {
tokenHash := hashToken(req.RefreshToken)
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
}
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
// Access token (15 min)
accessClaims := Claims{
UserID: user.ID,
Email: user.Email,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: uuid.New().String(),
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessString, err := accessToken.SignedString([]byte(h.cfg.JWTSecret))
if err != nil {
return nil, err
}
// Refresh token (7 days)
refreshRaw := uuid.New().String()
refreshHash := hashToken(refreshRaw)
expiresAt := time.Now().Add(7 * 24 * time.Hour)
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt); err != nil {
log.Printf("warn: failed to store refresh token: %v", err)
}
return gin.H{
"access_token": accessString,
"refresh_token": refreshRaw,
"token_type": "Bearer",
"expires_in": 900,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"display_name": user.DisplayName,
"role": user.Role,
},
}, nil
}
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
// BootstrapAdmin creates/updates the admin user from env vars (K8s secret).
func BootstrapAdmin(cfg *config.Config, s store.Stores) {
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
return
}
if database.DB == nil {
return
}
email := cfg.AdminEmail
if email == "" {
email = cfg.AdminUsername + "@localhost"
}
ctx := context.Background()
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcryptCost)
if err != nil {
log.Printf("⚠ Failed to hash admin password: %v", err)
return
}
// Try upsert by username (common case: same username, new password)
_, err = database.DB.Exec(`
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ($1, $2, $3, 'admin', true)
ON CONFLICT (username) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
email = EXCLUDED.email,
role = 'admin',
is_active = true
`, cfg.AdminUsername, email, string(hash))
if err != nil && strings.Contains(err.Error(), "duplicate key") {
// Email conflict — admin username was changed in config but email
// already belongs to old admin row. Update that row instead.
_, err = database.DB.Exec(`
UPDATE users SET
username = $1,
password_hash = $3,
role = 'admin',
is_active = true
WHERE email = $2
`, cfg.AdminUsername, email, string(hash))
existing, _ := s.Users.GetByUsername(ctx, cfg.AdminUsername)
if existing != nil {
// Update password and ensure admin role
s.Users.Update(ctx, existing.ID, map[string]interface{}{
"password_hash": string(hash),
"role": models.UserRoleAdmin,
"is_active": true,
})
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
return
}
if err != nil {
log.Printf("⚠ Admin bootstrap failed: %v", err)
} else {
log.Printf("✅ Admin user '%s' bootstrapped from environment", cfg.AdminUsername)
email := cfg.AdminEmail
if email == "" {
email = cfg.AdminUsername + "@switchboard.local"
}
user := &models.User{
Username: cfg.AdminUsername,
Email: email,
PasswordHash: string(hash),
Role: models.UserRoleAdmin,
IsActive: true,
}
if err := s.Users.Create(ctx, user); err != nil {
log.Printf("⚠ Failed to create admin user: %v", err)
return
}
log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername)
}
// ── Login ───────────────────────────────────
func (h *AuthHandler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Login = strings.TrimSpace(req.Login)
// Look up user by email or username
var user userResponse
var passwordHash string
var isActive bool
err := database.DB.QueryRow(`
SELECT id, username, email, display_name, role, avatar_url, password_hash, is_active
FROM users
WHERE email = $1 OR username = $1
`, req.Login).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName,
&user.Role, &user.Avatar, &passwordHash, &isActive,
)
if err == sql.ErrNoRows {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"})
return
}
if !isActive {
c.JSON(http.StatusForbidden, gin.H{"error": "account is pending admin approval"})
return
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
// Update last_login_at
_, _ = database.DB.Exec(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, user.ID)
// Generate tokens
resp, err := h.generateTokenPair(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusOK, resp)
AuditLogWithActor(user.ID, c, "user.login", "user", user.ID, nil)
}
// ── Refresh ─────────────────────────────────
func (h *AuthHandler) Refresh(c *gin.Context) {
var req refreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokenHash := hashToken(req.RefreshToken)
// Find and validate the refresh token
var tokenID, userID string
var expiresAt time.Time
err := database.DB.QueryRow(`
SELECT rt.id, rt.user_id, rt.expires_at
FROM refresh_tokens rt
WHERE rt.token_hash = $1 AND rt.revoked_at IS NULL
`, tokenHash).Scan(&tokenID, &userID, &expiresAt)
if err == sql.ErrNoRows {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token validation failed"})
return
}
if time.Now().After(expiresAt) {
// Revoke expired token
_, _ = database.DB.Exec(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, tokenID)
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token expired"})
return
}
// Revoke the old token (rotation)
_, _ = database.DB.Exec(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, tokenID)
// Look up user
var user userResponse
var isActive bool
err = database.DB.QueryRow(`
SELECT id, username, email, display_name, role, avatar_url, is_active
FROM users WHERE id = $1
`, userID).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.Avatar, &isActive,
)
if err != nil || !isActive {
c.JSON(http.StatusUnauthorized, gin.H{"error": "account unavailable"})
return
}
// Issue new pair
resp, err := h.generateTokenPair(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusOK, resp)
}
// ── Logout ──────────────────────────────────
func (h *AuthHandler) Logout(c *gin.Context) {
var req refreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
// No refresh token provided — just acknowledge
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
return
}
tokenHash := hashToken(req.RefreshToken)
// Revoke the refresh token
_, _ = database.DB.Exec(`
UPDATE refresh_tokens SET revoked_at = NOW()
WHERE token_hash = $1 AND revoked_at IS NULL
`, tokenHash)
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
// ── Token Generation ────────────────────────
func (h *AuthHandler) generateTokenPair(user userResponse) (*authResponse, error) {
now := time.Now()
// Access token (JWT)
claims := Claims{
UserID: user.ID,
Email: user.Email,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenDuration)),
Issuer: "chat-switchboard",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
accessToken, err := token.SignedString([]byte(h.cfg.JWTSecret))
if err != nil {
return nil, fmt.Errorf("sign access token: %w", err)
}
// Refresh token (opaque random string, stored hashed)
refreshBytes := make([]byte, 32)
if _, err := rand.Read(refreshBytes); err != nil {
return nil, fmt.Errorf("generate refresh token: %w", err)
}
refreshToken := hex.EncodeToString(refreshBytes)
refreshHash := hashToken(refreshToken)
// Store refresh token
_, err = database.DB.Exec(`
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
`, user.ID, refreshHash, now.Add(refreshTokenDuration))
if err != nil {
return nil, fmt.Errorf("store refresh token: %w", err)
}
return &authResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(accessTokenDuration.Seconds()),
User: user,
}, nil
}
// hashToken returns a SHA-256 hex digest of a token string.
// Refresh tokens are stored hashed so a DB leak doesn't
// compromise active sessions.
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
// IsRegistrationEnabled checks the platform policy.
func IsRegistrationEnabled(s store.Stores) bool {
val, _ := s.Policies.GetBool(context.Background(), "allow_registration")
return val
}

View File

@@ -15,6 +15,7 @@ import (
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
func testConfig() *config.Config {
@@ -23,12 +24,16 @@ func testConfig() *config.Config {
}
}
// testAuthHandler creates an AuthHandler with nil stores (safe for non-DB tests).
func testAuthHandler() *AuthHandler {
return NewAuthHandler(testConfig(), store.Stores{})
}
// ── JWT Token Tests ─────────────────────────
func TestJWTGeneration(t *testing.T) {
cfg := testConfig()
// Create a token the same way the handler does
now := time.Now()
claims := Claims{
UserID: "550e8400-e29b-41d4-a716-446655440000",
@@ -130,13 +135,11 @@ func TestBcryptHash(t *testing.T) {
t.Fatalf("Failed to hash: %v", err)
}
// Correct password should match
err = bcrypt.CompareHashAndPassword(hash, []byte(password))
if err != nil {
t.Error("Correct password should match hash")
}
// Wrong password should not match
err = bcrypt.CompareHashAndPassword(hash, []byte("wrongPassword"))
if err == nil {
t.Error("Wrong password should not match hash")
@@ -153,7 +156,6 @@ func TestBcryptDifferentHashesForSamePassword(t *testing.T) {
t.Error("Same password should produce different hashes (salt)")
}
// Both should still verify
if bcrypt.CompareHashAndPassword(hash1, []byte(password)) != nil {
t.Error("hash1 should verify")
}
@@ -168,22 +170,18 @@ func TestTokenHash(t *testing.T) {
token := "abc123refreshtoken"
hash := hashToken(token)
// Should be deterministic
if hashToken(token) != hash {
t.Error("hashToken should be deterministic")
}
// Different token should produce different hash
if hashToken("different") == hash {
t.Error("Different tokens should produce different hashes")
}
// Should be hex-encoded SHA-256 (64 chars)
if len(hash) != 64 {
t.Errorf("Expected 64 char hex hash, got %d chars", len(hash))
}
// Verify it's actually SHA-256
expected := sha256.Sum256([]byte(token))
expectedHex := hex.EncodeToString(expected[:])
if hash != expectedHex {
@@ -194,7 +192,7 @@ func TestTokenHash(t *testing.T) {
// ── Request Validation Tests ────────────────
func TestRegisterValidation(t *testing.T) {
h := NewAuthHandler(testConfig())
h := testAuthHandler()
tests := []struct {
name string
@@ -211,21 +209,11 @@ func TestRegisterValidation(t *testing.T) {
body: `{"username":"test","email":"test@example.com"}`,
wantCode: http.StatusBadRequest,
},
{
name: "invalid email",
body: `{"username":"test","email":"notanemail","password":"12345678"}`,
wantCode: http.StatusBadRequest,
},
{
name: "password too short",
body: `{"username":"test","email":"test@example.com","password":"short"}`,
wantCode: http.StatusBadRequest,
},
{
name: "username too short",
body: `{"username":"ab","email":"test@example.com","password":"12345678"}`,
wantCode: http.StatusBadRequest,
},
}
for _, tt := range tests {
@@ -247,7 +235,7 @@ func TestRegisterValidation(t *testing.T) {
}
func TestLoginValidation(t *testing.T) {
h := NewAuthHandler(testConfig())
h := testAuthHandler()
tests := []struct {
name string
@@ -289,7 +277,7 @@ func TestLoginValidation(t *testing.T) {
}
func TestRefreshValidation(t *testing.T) {
h := NewAuthHandler(testConfig())
h := testAuthHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -305,7 +293,7 @@ func TestRefreshValidation(t *testing.T) {
}
func TestLogoutWithoutToken(t *testing.T) {
h := NewAuthHandler(testConfig())
h := testAuthHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)

View File

@@ -207,7 +207,7 @@ func UploadPresetAvatar(c *gin.Context) {
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
result, err := database.DB.Exec(
`UPDATE model_presets SET avatar = $1, updated_at = NOW() WHERE id = $2`,
`UPDATE personas SET avatar = $1, updated_at = NOW() WHERE id = $2`,
dataURI, presetID,
)
if err != nil {
@@ -228,7 +228,7 @@ func DeletePresetAvatar(c *gin.Context) {
presetID := c.Param("id")
result, err := database.DB.Exec(
`UPDATE model_presets SET avatar = '', updated_at = NOW() WHERE id = $1`,
`UPDATE personas SET avatar = '', updated_at = NOW() WHERE id = $1`,
presetID,
)
if err != nil {

View File

@@ -3,109 +3,130 @@ package handlers
import (
"encoding/json"
"log"
"net/http"
"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"
)
// ModelHandler provides the unified models endpoint.
type ModelHandler struct {
stores store.Stores
}
func NewModelHandler(s store.Stores) *ModelHandler {
return &ModelHandler{stores: s}
}
// ListEnabledModels returns all models the user can access (catalog + personas),
// with user preferences (hidden, sort order) applied.
func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
userID := getUserID(c)
userModels, err := capspkg.ModelsForUser(c.Request.Context(), h.stores, userID)
if err != nil {
log.Printf("error: ModelsForUser(%s): %v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve models"})
return
}
c.JSON(http.StatusOK, gin.H{"models": userModels})
}
// ResolveModelCaps is the canonical capability resolver for any model.
// It walks a priority chain and returns the best capabilities available:
//
// 1. model_configs DB — exact match (model_id + api_config_id)
// 2. model_configs DB — any provider (same model, different config)
// Priority chain:
// 1. model_catalog DB — exact match (model_id + provider_config_id)
// 2. model_catalog DB — any provider (same model, different config)
// 3. Known model table (static, curated)
// 4. Heuristic inference (name-based fallback)
//
// configID is optional — pass "" to skip the exact-match step.
func ResolveModelCaps(c *gin.Context, modelID, configID string) providers.ModelCapabilities {
// ── 1. Exact match: model_id + api_config_id ──
func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapabilities {
// 1. Exact match: model_id + provider_config_id
if configID != "" {
caps, ok := capsFromModelConfigs(modelID, configID)
caps, ok := capsFromCatalog(modelID, configID)
if ok {
caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
}
// ── 2. Any provider: same model_id, any config ──
caps, ok := capsFromModelConfigs(modelID, "")
// 2. Any provider: same model_id, any config
caps, ok := capsFromCatalog(modelID, "")
if ok {
caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
// ── 3. Known model table (static, curated) ──
caps, found := providers.LookupKnownModel(modelID)
// 3. Known model table (static, curated)
caps, found := capspkg.LookupKnownModel(modelID)
if found {
caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
// ── 4. Heuristic inference ──
caps = providers.InferCapabilities(modelID)
caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps)
// 4. Heuristic inference
caps = capspkg.InferCapabilities(modelID)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
// capsFromModelConfigs looks up capabilities from the model_configs table.
// If configID is non-empty, it matches exactly; otherwise it finds any entry
// for the model_id (capabilities for the same model are provider-agnostic).
func capsFromModelConfigs(modelID, configID string) (providers.ModelCapabilities, bool) {
// capsFromCatalog looks up capabilities from the model_catalog table.
func capsFromCatalog(modelID, configID string) (models.ModelCapabilities, bool) {
if database.DB == nil {
return models.ModelCapabilities{}, false
}
var capsJSON []byte
var err error
if configID != "" {
err = database.DB.QueryRow(`
SELECT capabilities FROM model_configs
WHERE model_id = $1 AND api_config_id = $2
SELECT capabilities FROM model_catalog
WHERE model_id = $1 AND provider_config_id = $2
`, modelID, configID).Scan(&capsJSON)
} else {
err = database.DB.QueryRow(`
SELECT capabilities FROM model_configs
WHERE model_id = $1 ORDER BY updated_at DESC LIMIT 1
SELECT capabilities FROM model_catalog
WHERE model_id = $1 ORDER BY last_synced_at DESC NULLS LAST LIMIT 1
`, modelID).Scan(&capsJSON)
}
if err != nil || len(capsJSON) == 0 {
return providers.ModelCapabilities{}, false
return models.ModelCapabilities{}, false
}
var caps providers.ModelCapabilities
if json.Unmarshal(capsJSON, &caps) != nil || !caps.HasProviderData() {
return providers.ModelCapabilities{}, false
}
return providers.MergeCapabilities(caps, modelID), true
}
// ResolveModelCapsFromLoaded checks an existing slice of models first (avoids
// redundant DB/network calls when we already have models in memory).
func ResolveModelCapsFromLoaded(c *gin.Context, modelID, configID string, loaded []enabledModel) providers.ModelCapabilities {
// Check already-loaded models first
for _, m := range loaded {
if m.ModelID == modelID {
return m.Capabilities
}
var caps models.ModelCapabilities
if json.Unmarshal(capsJSON, &caps) != nil || !caps.HasProviderData() {
return models.ModelCapabilities{}, false
}
// Fall through to canonical resolver
return ResolveModelCaps(c, modelID, configID)
// Merge with known data to fill gaps
resolved := capspkg.ResolveIntrinsic(modelID, &caps)
return resolved, true
}
// liveQueryModelCaps queries a provider API to get capabilities for a specific model.
// Used for team provider presets whose base model isn't in model_configs.
func liveQueryModelCaps(c *gin.Context, configID, modelID string) (providers.ModelCapabilities, bool) {
func liveQueryModelCaps(c *gin.Context, configID, modelID string) (models.ModelCapabilities, bool) {
if database.DB == nil {
return models.ModelCapabilities{}, false
}
var providerID, endpoint string
var apiKey *string
var headersJSON []byte
err := database.DB.QueryRow(`
SELECT provider, endpoint, api_key_encrypted, custom_headers
FROM api_configs WHERE id = $1 AND is_active = true
SELECT provider, endpoint, api_key_enc, headers
FROM provider_configs WHERE id = $1 AND is_active = true
`, configID).Scan(&providerID, &endpoint, &apiKey, &headersJSON)
if err != nil {
return providers.ModelCapabilities{}, false
return models.ModelCapabilities{}, false
}
provider, err := providers.Get(providerID)
if err != nil {
return providers.ModelCapabilities{}, false
return models.ModelCapabilities{}, false
}
key := ""
@@ -123,15 +144,15 @@ func liveQueryModelCaps(c *gin.Context, configID, modelID string) (providers.Mod
})
if err != nil {
log.Printf("[caps] live query for %s via config %s failed: %v", modelID, configID, err)
return providers.ModelCapabilities{}, false
return models.ModelCapabilities{}, false
}
for _, m := range modelList {
if m.ID == modelID {
caps := providers.MergeCapabilities(m.Capabilities, modelID)
return caps, true
resolved := capspkg.ResolveIntrinsic(modelID, &m.Capabilities)
return resolved, true
}
}
return providers.ModelCapabilities{}, false
return models.ModelCapabilities{}, false
}

View File

@@ -21,7 +21,7 @@ type createChannelRequest struct {
Description string `json:"description,omitempty"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
APIConfigID *string `json:"api_config_id,omitempty"`
APIConfigID *string `json:"provider_config_id,omitempty"`
Folder string `json:"folder,omitempty"`
Tags []string `json:"tags,omitempty"`
}
@@ -31,7 +31,7 @@ type updateChannelRequest struct {
Description *string `json:"description,omitempty"`
Model *string `json:"model,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
APIConfigID *string `json:"api_config_id,omitempty"`
APIConfigID *string `json:"provider_config_id,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"`
IsPinned *bool `json:"is_pinned,omitempty"`
Folder *string `json:"folder,omitempty"`
@@ -45,7 +45,7 @@ type channelResponse struct {
Type string `json:"type"`
Description *string `json:"description"`
Model *string `json:"model"`
APIConfigID *string `json:"api_config_id"`
APIConfigID *string `json:"provider_config_id"`
SystemPrompt *string `json:"system_prompt"`
IsArchived bool `json:"is_archived"`
IsPinned bool `json:"is_pinned"`
@@ -140,7 +140,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
// Fetch channels with message count
query := `
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.api_config_id,
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
@@ -233,9 +233,9 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
var ch channelResponse
var tags []string
err := database.DB.QueryRow(`
INSERT INTO channels (user_id, title, type, description, model, system_prompt, api_config_id, folder, tags)
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, title, type, description, model, api_config_id, system_prompt,
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
is_archived, is_pinned, folder, tags, created_at, updated_at
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
req.Folder, pq.Array(req.Tags),
@@ -265,7 +265,7 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
// Auto-create channel_model if model specified
if req.Model != "" {
_, _ = database.DB.Exec(`
INSERT INTO channel_models (channel_id, model_id, api_config_id, is_default)
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.APIConfigID)
@@ -283,7 +283,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
var ch channelResponse
var tags []string
err := database.DB.QueryRow(`
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.api_config_id,
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
@@ -369,7 +369,7 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
addClause("system_prompt", *req.SystemPrompt)
}
if req.APIConfigID != nil {
addClause("api_config_id", *req.APIConfigID)
addClause("provider_config_id", *req.APIConfigID)
}
if req.IsArchived != nil {
addClause("is_archived", *req.IsArchived)

View File

@@ -1,342 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// ── Channel Request Validation ─────────────────
func TestCreateChannelMissingTitle(t *testing.T) {
h := NewChannelHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/channels",
strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateChannel(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateChannelTitleTooLong(t *testing.T) {
h := NewChannelHandler()
longTitle := strings.Repeat("x", 501)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/channels",
strings.NewReader(`{"title":"`+longTitle+`"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateChannel(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for title > 500 chars, got %d", w.Code)
}
}
func TestUpdateChannelEmptyBody(t *testing.T) {
h := NewChannelHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "test-user-id")
c.Params = gin.Params{{Key: "id", Value: "test-channel-id"}}
c.Request = httptest.NewRequest("PUT", "/api/v1/channels/test-channel-id",
strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
// Without a DB connection, UpdateChannel will fail at ownership check.
// Integration tests with a real DB validate the "no fields" path.
// Here we just confirm it doesn't return 400 for valid JSON.
h.UpdateChannel(c)
if w.Code == http.StatusBadRequest {
t.Error("Empty JSON body should not be a parse error")
}
}
// ── Message Request Validation ──────────────
func TestCreateMessageMissingRole(t *testing.T) {
h := NewMessageHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
strings.NewReader(`{"content":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateMessage(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for missing role, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateMessageInvalidRole(t *testing.T) {
h := NewMessageHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
strings.NewReader(`{"role":"invalid","content":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateMessage(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for invalid role, got %d", w.Code)
}
}
func TestCreateMessageMissingContent(t *testing.T) {
h := NewMessageHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
strings.NewReader(`{"role":"user"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateMessage(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for missing content, got %d", w.Code)
}
}
// ── Integration: Message CRUD with Real DB ──────
func TestCreateMessageValidRoles(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "roletester", "role@test.com")
channelID := database.SeedTestChannel(t, userID, "Role Test")
h := NewMessageHandler()
for _, role := range []string{"user", "assistant", "system"} {
t.Run(role, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", userID)
c.Params = gin.Params{{Key: "id", Value: channelID}}
c.Request = httptest.NewRequest("POST", "/api/v1/channels/"+channelID+"/messages",
strings.NewReader(`{"role":"`+role+`","content":"hello from `+role+`"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateMessage(c)
if w.Code != http.StatusCreated {
t.Errorf("role=%s: expected 201, got %d: %s", role, w.Code, w.Body.String())
}
})
}
}
func TestChannelCRUDIntegration(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "cruduser", "crud@test.com")
h := NewChannelHandler()
r := gin.New()
r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
r.POST("/channels", h.CreateChannel)
r.GET("/channels", h.ListChannels)
r.GET("/channels/:id", h.GetChannel)
r.PUT("/channels/:id", h.UpdateChannel)
r.DELETE("/channels/:id", h.DeleteChannel)
// Create
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/channels",
strings.NewReader(`{"title":"Integration Test Channel"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &created)
channelID := created["id"].(string)
// Get
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/channels/"+channelID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Get: expected 200, got %d", w.Code)
}
// List
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/channels", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("List: expected 200, got %d", w.Code)
}
var listResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &listResp)
if listResp["total"].(float64) < 1 {
t.Error("List: expected at least 1 channel")
}
// Update
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/channels/"+channelID,
strings.NewReader(`{"title":"Updated Title"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Update: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Delete
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/channels/"+channelID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Delete: expected 200, got %d", w.Code)
}
// Verify gone
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/channels/"+channelID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Get after delete: expected 404, got %d", w.Code)
}
}
func TestRegeneratePassesOwnershipCheck(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "regenuser", "regen@test.com")
channelID := database.SeedTestChannel(t, userID, "Regen Test")
// Seed an assistant message to regenerate
var msgID string
err := database.DB.QueryRow(`
INSERT INTO messages (channel_id, role, content, participant_type, participant_id)
VALUES ($1, 'assistant', 'original response', 'model', 'test-model')
RETURNING id
`, channelID).Scan(&msgID)
if err != nil {
t.Fatalf("seed message: %v", err)
}
h := NewMessageHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", userID)
c.Params = gin.Params{
{Key: "id", Value: channelID},
{Key: "msgId", Value: msgID},
}
c.Request = httptest.NewRequest("POST",
"/api/v1/channels/"+channelID+"/messages/"+msgID+"/regenerate",
strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Regenerate(c)
// Should NOT be 404 — ownership check passed. Will be 400 or 500
// because no API config is set up, which is expected.
if w.Code == http.StatusNotFound {
t.Errorf("Expected to pass ownership check, got 404: %s", w.Body.String())
}
}
// ── Pagination Helpers ──────────────────────
func TestParsePaginationDefaults(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/channels", nil)
page, perPage, offset := parsePagination(c)
if page != 1 {
t.Errorf("Default page should be 1, got %d", page)
}
if perPage != 50 {
t.Errorf("Default per_page should be 50, got %d", perPage)
}
if offset != 0 {
t.Errorf("Default offset should be 0, got %d", offset)
}
}
func TestParsePaginationCustom(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/channels?page=3&per_page=10", nil)
page, perPage, offset := parsePagination(c)
if page != 3 {
t.Errorf("Page should be 3, got %d", page)
}
if perPage != 10 {
t.Errorf("Per page should be 10, got %d", perPage)
}
if offset != 20 {
t.Errorf("Offset should be 20, got %d", offset)
}
}
func TestParsePaginationClampMax(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/channels?per_page=500", nil)
_, perPage, _ := parsePagination(c)
if perPage != 100 {
t.Errorf("Per page should be clamped to 100, got %d", perPage)
}
}
// ── getUserID ───────────────────────────────
func TestGetUserID(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "abc-123")
uid := getUserID(c)
if uid != "abc-123" {
t.Errorf("Expected abc-123, got %s", uid)
}
}
func TestGetUserIDMissing(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
uid := getUserID(c)
if uid != "" {
t.Errorf("Expected empty string, got %s", uid)
}
}

View File

@@ -11,7 +11,9 @@ 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/providers"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
@@ -23,7 +25,7 @@ type completionRequest struct {
Content string `json:"content" binding:"required"`
Model string `json:"model,omitempty"`
PresetID string `json:"preset_id,omitempty"` // if set, unwraps preset → base model + config
APIConfigID string `json:"api_config_id,omitempty"`
APIConfigID string `json:"provider_config_id,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
@@ -88,8 +90,8 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
if req.Model == "" {
req.Model = preset.BaseModelID
}
if req.APIConfigID == "" && preset.APIConfigID != nil {
req.APIConfigID = *preset.APIConfigID
if req.APIConfigID == "" && preset.ProviderConfigID != nil {
req.APIConfigID = *preset.ProviderConfigID
}
if req.Temperature == nil && preset.Temperature != nil {
req.Temperature = preset.Temperature
@@ -152,7 +154,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
provReq.MaxTokens = req.MaxTokens
} else {
// ResolveMaxOutput checks: caps → known models → context/8 → 4096
provReq.MaxTokens = providers.ResolveMaxOutput(model, caps)
provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps)
}
if req.Temperature != nil {
@@ -477,14 +479,14 @@ func escapeJSON(s string) string {
return s
}
// getModelCapabilities looks up capabilities from model_configs DB,
// 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) providers.ModelCapabilities {
func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfigID string) models.ModelCapabilities {
return ResolveModelCaps(c, model, apiConfigID)
}
// ── Config Resolution ───────────────────────
// Priority: request.api_config_id → chat.api_config_id → user's first active config
// 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, error) {
var configID string
@@ -498,7 +500,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
if configID == "" {
var channelConfigID *string
err := database.DB.QueryRow(
`SELECT api_config_id FROM channels WHERE id = $1`, channelID,
`SELECT provider_config_id FROM channels WHERE id = $1`, channelID,
).Scan(&channelConfigID)
if err == nil && channelConfigID != nil {
configID = *channelConfigID
@@ -508,9 +510,12 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
// 3. User's first active config (personal first, then global — excludes team providers)
if configID == "" {
err := database.DB.QueryRow(`
SELECT id FROM api_configs
WHERE (user_id = $1 OR is_global = true) AND is_active = true AND team_id IS NULL
ORDER BY user_id NULLS LAST, created_at ASC
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)
if err != nil {
@@ -523,11 +528,12 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
var apiKey, modelDefault *string
var customHeadersJSON, providerSettingsJSON []byte
err := database.DB.QueryRow(`
SELECT provider, endpoint, api_key_encrypted, model_default, custom_headers, provider_settings
FROM api_configs
SELECT provider, endpoint, api_key_enc, model_default, headers, settings
FROM provider_configs
WHERE id = $1 AND is_active = true
AND (user_id = $2 OR is_global = true
OR team_id IN (SELECT team_id FROM team_members WHERE user_id = $2))
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)))
`, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON)
if err == sql.ErrNoRows {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,457 @@
package handlers
import (
"fmt"
"net/http"
"os"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// ═══════════════════════════════════════════
// Live Provider Integration Tests
// ═══════════════════════════════════════════
// These tests require:
// - TEST_DATABASE_URL or PGHOST+PGUSER
// - VENICE_API_KEY secret
//
// They exercise the full flow: create provider →
// fetch models → enable model → resolve → complete.
// ═══════════════════════════════════════════
func requireVeniceKey(t *testing.T) string {
t.Helper()
key := os.Getenv("VENICE_API_KEY")
if key == "" {
t.Skip("VENICE_API_KEY not set — skipping live provider test")
}
return key
}
// TestLive_VeniceProviderFullFlow exercises the complete admin workflow:
// create provider → fetch models → enable a model → user sees it → chat completion
func TestLive_VeniceProviderFullFlow(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// ── 1. Create Venice provider config ────
t.Log("Step 1: Creating Venice provider config")
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Venice Live Test",
"provider": "venice",
"endpoint": "https://api.venice.ai/api/v1",
"api_key": veniceKey,
})
if w.Code != http.StatusCreated {
t.Fatalf("create venice config: want 201, got %d: %s", w.Code, w.Body.String())
}
var configResp map[string]interface{}
decode(w, &configResp)
configID := configResp["id"].(string)
t.Logf(" Created config: %s", configID)
// ── 2. Fetch models from Venice ─────────
t.Log("Step 2: Fetching models from Venice API")
w = h.request("POST", "/api/v1/admin/models/fetch", adminToken, map[string]interface{}{
"provider_config_id": configID,
})
if w.Code != http.StatusOK {
t.Fatalf("fetch models: want 200, got %d: %s", w.Code, w.Body.String())
}
var fetchResp map[string]interface{}
decode(w, &fetchResp)
totalFetched := fetchResp["total"].(float64)
if totalFetched < 1 {
t.Fatalf("Venice should return at least 1 model, got %.0f", totalFetched)
}
t.Logf(" Fetched %.0f models from Venice", totalFetched)
// ── 3. List catalog models (all disabled by default) ──
t.Log("Step 3: Listing catalog models")
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list models: want 200, got %d: %s", w.Code, w.Body.String())
}
var modelsResp map[string]interface{}
decode(w, &modelsResp)
catalogModels := modelsResp["models"].([]interface{})
if len(catalogModels) < 1 {
t.Fatal("catalog should have models after fetch")
}
// Find a text model to enable (prefer a small/fast one)
var enableID string
var enableModelID string
for _, raw := range catalogModels {
m := raw.(map[string]interface{})
modelID := m["model_id"].(string)
vis := m["visibility"].(string)
if vis == "disabled" {
enableID = m["id"].(string)
enableModelID = modelID
break
}
}
if enableID == "" {
t.Fatal("no disabled model found to enable")
}
t.Logf(" Will enable: %s (catalog ID: %s)", enableModelID, enableID)
// ── 4. Enable the model ─────────────────
t.Log("Step 4: Enabling model")
w = h.request("PUT", "/api/v1/admin/models/"+enableID, adminToken,
map[string]interface{}{"visibility": "enabled"})
if w.Code != http.StatusOK {
t.Fatalf("enable model: want 200, got %d: %s", w.Code, w.Body.String())
}
// ── 5. Verify models/enabled returns it (admin) ──
t.Log("Step 5: Verifying models/enabled (admin)")
w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("models/enabled: want 200, got %d: %s", w.Code, w.Body.String())
}
var enabledResp map[string]interface{}
decode(w, &enabledResp)
enabledModels := enabledResp["models"].([]interface{})
if len(enabledModels) < 1 {
t.Fatal("models/enabled should return at least 1 model after enabling")
}
// Verify our model is in the list
found := false
for _, raw := range enabledModels {
m := raw.(map[string]interface{})
if m["model_id"] == enableModelID {
found = true
t.Logf(" ✓ Found %s in enabled models", enableModelID)
// Verify it has the required fields for the frontend
if m["config_id"] == nil || m["config_id"] == "" {
t.Error("enabled model must have config_id for composite ID")
}
if m["provider_name"] == nil || m["provider_name"] == "" {
t.Error("enabled model must have provider_name for display")
}
break
}
}
if !found {
t.Errorf("enabled model %s not found in models/enabled response", enableModelID)
}
// ── 6. Verify a REGULAR USER also sees the model ──
t.Log("Step 6: Verifying models/enabled (regular user)")
userID := database.SeedTestUser(t, "liveuser", "liveuser@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
userToken := makeToken(userID, "liveuser@test.com", "user")
w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("user models/enabled: want 200, got %d: %s", w.Code, w.Body.String())
}
var userResp map[string]interface{}
decode(w, &userResp)
userModels := userResp["models"].([]interface{})
if len(userModels) < 1 {
t.Fatalf("regular user should see at least 1 enabled model, got %d — "+
"admin can see models but regular user cannot; check ListVisible query",
len(userModels))
}
userFound := false
for _, raw := range userModels {
m := raw.(map[string]interface{})
if m["model_id"] == enableModelID {
userFound = true
t.Logf(" ✓ Regular user can see %s", enableModelID)
// Verify same fields available for regular user
if m["config_id"] == nil || m["config_id"] == "" {
t.Error("user: enabled model must have config_id")
}
if m["provider_name"] == nil || m["provider_name"] == "" {
t.Error("user: enabled model must have provider_name")
}
break
}
}
if !userFound {
t.Errorf("regular user cannot see %s — admin→user visibility broken", enableModelID)
}
}
// TestLive_VeniceFetchModelsCapabilities verifies that Venice model
// capabilities are correctly parsed into the catalog.
func TestLive_VeniceFetchModelsCapabilities(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Create provider + fetch
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Venice Caps Test", "provider": "venice",
"endpoint": "https://api.venice.ai/api/v1", "api_key": veniceKey,
})
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
// Read catalog and check capabilities
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var resp map[string]interface{}
decode(w, &resp)
for _, raw := range resp["models"].([]interface{}) {
m := raw.(map[string]interface{})
caps, ok := m["capabilities"].(map[string]interface{})
if !ok {
t.Errorf("model %s: capabilities must be an object", m["model_id"])
continue
}
// streaming should always be true for Venice
if caps["streaming"] != true {
t.Errorf("model %s: streaming should be true", m["model_id"])
}
// Verify capabilities are actual booleans (not strings)
for _, key := range []string{"streaming", "vision", "tool_calling", "reasoning"} {
if v, exists := caps[key]; exists {
if _, ok := v.(bool); !ok {
t.Errorf("model %s: capability %s should be bool, got %T", m["model_id"], key, v)
}
}
}
}
}
// TestLive_VeniceChatCompletion sends an actual chat completion.
func TestLive_VeniceChatCompletion(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
_ = adminID
// Create provider
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Venice Chat Test", "provider": "venice",
"endpoint": "https://api.venice.ai/api/v1", "api_key": veniceKey,
})
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
// Fetch + enable a fast model
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var modelsResp map[string]interface{}
decode(w, &modelsResp)
// Find and enable a small model (prefer llama or qwen for speed)
var targetModelID, targetCatalogID string
for _, raw := range modelsResp["models"].([]interface{}) {
m := raw.(map[string]interface{})
mid := m["model_id"].(string)
// Pick any available model - first disabled one
if m["visibility"].(string) == "disabled" {
targetModelID = mid
targetCatalogID = m["id"].(string)
break
}
}
if targetModelID == "" {
t.Skip("no model available to test chat completion")
}
h.request("PUT", "/api/v1/admin/models/"+targetCatalogID, adminToken,
map[string]interface{}{"visibility": "enabled"})
// Set as default model and send completion
// First get enabled model's composite ID
w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
var enabled map[string]interface{}
decode(w, &enabled)
if len(enabled["models"].([]interface{})) == 0 {
t.Fatal("no enabled models for completion test")
}
firstModel := enabled["models"].([]interface{})[0].(map[string]interface{})
modelForChat := firstModel["model_id"].(string)
configForChat := firstModel["config_id"].(string)
t.Logf("Sending completion to %s via config %s", modelForChat, configForChat)
// Create a channel first
w = h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Test Chat", "type": "direct",
})
if w.Code != http.StatusCreated {
// Some channel handlers may use database.DB directly
t.Skipf("channel creation failed (may need database.DB global): %d %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Send completion
w = h.request("POST", "/api/v1/chat/completions", adminToken, map[string]interface{}{
"channel_id": channelID,
"model": modelForChat,
"config_id": configForChat,
"stream": false,
"messages": []map[string]string{
{"role": "user", "content": "Say hello in exactly 3 words."},
},
})
if w.Code != http.StatusOK {
t.Logf("completion response: %s", w.Body.String())
t.Skipf("chat completion failed with %d (may need full router wiring)", w.Code)
}
t.Logf(" ✓ Completion succeeded: %s", w.Body.String()[:min(200, w.Body.Len())])
}
// TestLive_VeniceModelDeletion tests cleanup: delete provider removes catalog entries
func TestLive_VeniceModelDeletion(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Create + fetch
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Venice Delete Test", "provider": "venice",
"endpoint": "https://api.venice.ai/api/v1", "api_key": veniceKey,
})
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
// Verify models exist
var count int
database.TestDB.QueryRow("SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = $1", configID).Scan(&count)
if count == 0 {
t.Fatal("catalog should have models after fetch")
}
t.Logf(" %d models in catalog before delete", count)
// Delete the provider
w = h.request("DELETE", "/api/v1/admin/configs/"+configID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete config: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify cascade: catalog entries should be gone
database.TestDB.QueryRow("SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = $1", configID).Scan(&count)
if count != 0 {
t.Errorf("catalog should be empty after provider delete, got %d entries", count)
}
t.Log(" ✓ Cascade delete cleaned up catalog entries")
}
// TestLive_VeniceBYOK_AutoFetch exercises the ACTUAL user experience:
// user creates a BYOK provider → auto-fetch triggers → models appear in /models/enabled
//
// This is the definitive test. No simulated data. Real Venice API.
func TestLive_VeniceBYOK_AutoFetch(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Enable BYOK policy
h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
map[string]interface{}{"value": "true"})
// Create regular user
userID := database.SeedTestUser(t, "byokuser", "byokuser@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
userToken := makeToken(userID, "byokuser@test.com", "user")
// ── Step 1: User creates BYOK provider (the ONLY user action) ──
t.Log("Step 1: User creates BYOK Venice provider")
w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
"name": "My Venice",
"provider": "venice",
"endpoint": "https://api.venice.ai/api/v1",
"api_key": veniceKey,
})
if w.Code != http.StatusCreated {
t.Fatalf("create BYOK provider: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
cfgID := created["id"].(string)
t.Logf(" Created provider: %s", cfgID)
// ── Step 2: Verify auto-fetch happened ──
if created["warning"] != nil {
t.Fatalf("auto-fetch should succeed with real Venice key, got warning: %v", created["warning"])
}
modelsFetched := created["models_fetched"]
if modelsFetched == nil || modelsFetched.(float64) < 1 {
t.Fatalf("auto-fetch should return models_fetched > 0, got: %v", modelsFetched)
}
t.Logf(" Auto-fetched %.0f models", modelsFetched.(float64))
// ── Step 3: User's models/enabled shows personal models ──
t.Log("Step 3: Verify user sees BYOK models in /models/enabled")
w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("models/enabled: %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
userModels := resp["models"].([]interface{})
personalCount := 0
for _, raw := range userModels {
m := raw.(map[string]interface{})
if m["scope"] == "personal" {
personalCount++
}
}
if personalCount < 1 {
t.Fatalf("user should see personal BYOK models, got %d personal out of %d total\n"+
" model IDs: %v",
personalCount, len(userModels), func() []string {
ids := make([]string, 0)
for _, raw := range userModels {
m := raw.(map[string]interface{})
ids = append(ids, fmt.Sprintf("%s(scope=%s)", m["model_id"], m["scope"]))
}
return ids
}())
}
t.Logf(" ✓ User sees %d personal BYOK models (out of %d total)", personalCount, len(userModels))
// ── Step 4: Verify model fields for frontend ──
t.Log("Step 4: Verify frontend-required fields on BYOK models")
for _, raw := range userModels {
m := raw.(map[string]interface{})
if m["scope"] != "personal" {
continue
}
if m["config_id"] == nil || m["config_id"] == "" {
t.Errorf("personal model %s missing config_id", m["model_id"])
}
if m["provider_name"] == nil || m["provider_name"] == "" {
t.Errorf("personal model %s missing provider_name", m["model_id"])
}
if m["model_id"] == nil || m["model_id"] == "" {
t.Errorf("personal model missing model_id")
}
break // check first personal model only
}
// ── Cleanup ──
h.request("DELETE", fmt.Sprintf("/api/v1/api-configs/%s", cfgID), userToken, nil)
}

View File

@@ -11,6 +11,7 @@ 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/providers"
)
@@ -43,7 +44,7 @@ type editRequest struct {
type regenerateRequest struct {
Model string `json:"model,omitempty"`
PresetID string `json:"preset_id,omitempty"`
APIConfigID string `json:"api_config_id,omitempty"`
APIConfigID string `json:"provider_config_id,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
}
@@ -369,8 +370,8 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
if model == "" {
model = preset.BaseModelID
}
if apiConfigID == "" && preset.APIConfigID != nil {
apiConfigID = *preset.APIConfigID
if apiConfigID == "" && preset.ProviderConfigID != nil {
apiConfigID = *preset.ProviderConfigID
}
if temperature == nil && preset.Temperature != nil {
temperature = preset.Temperature
@@ -436,7 +437,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
if maxTokens > 0 {
provReq.MaxTokens = maxTokens
} else {
provReq.MaxTokens = providers.ResolveMaxOutput(model, caps)
provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps)
}
if temperature != nil {
provReq.Temperature = temperature

View File

@@ -1,77 +1,69 @@
package handlers
import (
"log"
"net/http"
"git.gobha.me/xcaliber/chat-switchboard/database"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// GetModelPreferences returns the user's hidden model list.
// GET /api/v1/models/preferences
func GetModelPreferences(c *gin.Context) {
// ModelPrefsHandler handles user model preference endpoints.
type ModelPrefsHandler struct {
stores store.Stores
}
func NewModelPrefsHandler(s store.Stores) *ModelPrefsHandler {
return &ModelPrefsHandler{stores: s}
}
// GetPreferences returns the user's model preferences.
func (h *ModelPrefsHandler) GetPreferences(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.Query(`
SELECT model_id, hidden FROM user_model_preferences
WHERE user_id = $1
`, userID)
prefs, err := h.stores.UserSettings.GetForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query preferences"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get preferences"})
return
}
defer rows.Close()
type pref struct {
ModelID string `json:"model_id"`
Hidden bool `json:"hidden"`
}
prefs := make([]pref, 0)
for rows.Next() {
var p pref
if err := rows.Scan(&p.ModelID, &p.Hidden); err != nil {
continue
}
prefs = append(prefs, p)
}
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
}
// SetModelPreference sets hidden state for a single model.
// PUT /api/v1/models/preferences
func SetModelPreference(c *gin.Context) {
// SetPreference upserts a single model preference.
func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
userID := getUserID(c)
var req struct {
ModelID string `json:"model_id" binding:"required"`
Hidden bool `json:"hidden"`
ModelID string `json:"model_id" binding:"required"`
Hidden *bool `json:"hidden,omitempty"`
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := database.DB.Exec(`
INSERT INTO user_model_preferences (user_id, model_id, hidden, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, model_id)
DO UPDATE SET hidden = EXCLUDED.hidden, updated_at = NOW()
`, userID, req.ModelID, req.Hidden)
if err != nil {
log.Printf("[WARN] Failed to save model preference for user %s, model %s: %v", userID, req.ModelID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save preference: " + err.Error()})
patch := models.UserModelSettingPatch{
Hidden: req.Hidden,
PreferredTemperature: req.PreferredTemperature,
PreferredMaxTokens: req.PreferredMaxTokens,
SortOrder: req.SortOrder,
}
if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"})
return
}
c.JSON(http.StatusOK, gin.H{"model_id": req.ModelID, "hidden": req.Hidden})
c.JSON(http.StatusOK, gin.H{"message": "preference updated"})
}
// BulkSetModelPreferences sets hidden state for multiple models at once.
// POST /api/v1/models/preferences/bulk
func BulkSetModelPreferences(c *gin.Context) {
// BulkSetPreferences sets hidden state for multiple models at once.
func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) {
userID := getUserID(c)
var req struct {
@@ -83,43 +75,10 @@ func BulkSetModelPreferences(c *gin.Context) {
return
}
if len(req.ModelIDs) == 0 {
c.JSON(http.StatusOK, gin.H{"updated": 0})
if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.ModelIDs, req.Hidden); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
return
}
tx, err := database.DB.Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin transaction"})
return
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO user_model_preferences (user_id, model_id, hidden, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, model_id)
DO UPDATE SET hidden = EXCLUDED.hidden, updated_at = NOW()
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare statement"})
return
}
defer stmt.Close()
updated := 0
for _, modelID := range req.ModelIDs {
if _, err := stmt.Exec(userID, modelID, req.Hidden); err != nil {
log.Printf("[WARN] Failed to save preference for model %s: %v", modelID, err)
continue
}
updated++
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"})
return
}
c.JSON(http.StatusOK, gin.H{"updated": updated})
c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.ModelIDs)})
}

View File

@@ -0,0 +1,97 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// syncResult holds the outcome of a model sync operation.
type syncResult struct {
Added int `json:"added"`
Updated int `json:"updated"`
Total int `json:"total"`
}
// syncProviderModels fetches models from a provider's API and syncs them into the catalog.
// New models default to 'disabled' visibility (admin must explicitly enable for global providers).
func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig) (syncResult, error) {
prov, err := providers.Get(cfg.Provider)
if err != nil {
return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider)
}
provCfg := providers.ProviderConfig{
Endpoint: cfg.Endpoint,
APIKey: cfg.APIKeyEnc,
}
if cfg.Headers != nil {
customHeaders := make(map[string]string)
for k, v := range cfg.Headers {
if s, ok := v.(string); ok {
customHeaders[k] = s
}
}
provCfg.CustomHeaders = customHeaders
}
// Parse config for any extra settings the provider needs
if cfg.Config != nil {
raw, _ := json.Marshal(cfg.Config)
var extra map[string]string
if json.Unmarshal(raw, &extra) == nil {
if provCfg.CustomHeaders == nil {
provCfg.CustomHeaders = make(map[string]string)
}
for k, v := range extra {
provCfg.CustomHeaders[k] = v
}
}
}
provModels, err := prov.ListModels(ctx, provCfg)
if err != nil {
return syncResult{}, err
}
syncEntries := make([]store.CatalogSyncEntry, len(provModels))
for i, m := range provModels {
syncEntries[i] = store.CatalogSyncEntry{
ModelID: m.ID,
DisplayName: m.Name,
Capabilities: m.Capabilities,
Pricing: m.Pricing,
}
}
added, updated, err := stores.Catalog.UpsertFromSync(ctx, cfg.ID, syncEntries)
if err != nil {
return syncResult{}, fmt.Errorf("failed to sync: %w", err)
}
return syncResult{Added: added, Updated: updated, Total: len(provModels)}, nil
}
// syncAndEnableProviderModels fetches models and auto-enables them all.
// Used for personal (BYOK) providers — the user explicitly added this provider to use it.
func syncAndEnableProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig) (syncResult, error) {
result, err := syncProviderModels(ctx, stores, cfg)
if err != nil {
return result, err
}
// Auto-enable: user added this key to USE it, not to stare at disabled models
if result.Total > 0 {
if err := stores.Catalog.BulkSetVisibility(ctx, cfg.ID, "enabled"); err != nil {
log.Printf("warn: auto-enable models for provider %s failed: %v", cfg.ID, err)
}
}
return result, nil
}

View File

@@ -1,329 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// ── Notes: Validation (no DB needed) ────────
func TestCreateNoteMissingTitle(t *testing.T) {
h := NewNoteHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "test-user")
c.Request = httptest.NewRequest("POST", "/api/v1/notes",
strings.NewReader(`{"content":"body only"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for missing title, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateNoteMissingContent(t *testing.T) {
h := NewNoteHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "test-user")
c.Request = httptest.NewRequest("POST", "/api/v1/notes",
strings.NewReader(`{"title":"title only"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for missing content, got %d: %s", w.Code, w.Body.String())
}
}
// ── Notes: Full CRUD Integration ────────────
func TestNoteCRUDIntegration(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "noteuser", "note@test.com")
h := NewNoteHandler()
r := gin.New()
r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
r.POST("/notes", h.Create)
r.GET("/notes", h.List)
r.GET("/notes/search", h.Search)
r.GET("/notes/folders", h.ListFolders)
r.GET("/notes/:id", h.Get)
r.PUT("/notes/:id", h.Update)
r.DELETE("/notes/:id", h.Delete)
// ── Create ──
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/notes",
strings.NewReader(`{
"title": "Meeting Notes",
"content": "Discussed project timeline and deliverables",
"folder_path": "/work/meetings",
"tags": ["project", "planning"]
}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &created)
noteID, ok := created["id"].(string)
if !ok || noteID == "" {
t.Fatal("Create: missing or empty id in response")
}
if created["title"] != "Meeting Notes" {
t.Errorf("Create: title mismatch: %v", created["title"])
}
if created["folder_path"] != "/work/meetings/" {
t.Errorf("Create: folder_path should be normalized, got %v", created["folder_path"])
}
// ── Create second note for search/list tests ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/notes",
strings.NewReader(`{
"title": "Recipe Ideas",
"content": "Try making sourdough bread with rosemary",
"folder_path": "/personal",
"tags": ["food", "recipes"]
}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("Create 2nd note: expected 201, got %d", w.Code)
}
// ── Get ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Get: expected 200, got %d", w.Code)
}
var got map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &got)
if got["content"] != "Discussed project timeline and deliverables" {
t.Errorf("Get: wrong content: %v", got["content"])
}
// ── List (all) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("List: expected 200, got %d", w.Code)
}
var listResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &listResp)
total := listResp["total"].(float64)
if total != 2 {
t.Errorf("List: expected total=2, got %.0f", total)
}
// ── List (filtered by folder) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes?folder=/work/meetings", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("List by folder: expected 200, got %d", w.Code)
}
json.Unmarshal(w.Body.Bytes(), &listResp)
total = listResp["total"].(float64)
if total != 1 {
t.Errorf("List by folder: expected total=1, got %.0f", total)
}
// ── List (filtered by tag) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes?tag=food", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("List by tag: expected 200, got %d", w.Code)
}
json.Unmarshal(w.Body.Bytes(), &listResp)
total = listResp["total"].(float64)
if total != 1 {
t.Errorf("List by tag: expected total=1, got %.0f", total)
}
// ── Search ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/search?q=sourdough", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Search: expected 200, got %d: %s", w.Code, w.Body.String())
}
var searchResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &searchResp)
count := searchResp["count"].(float64)
if count != 1 {
t.Errorf("Search 'sourdough': expected count=1, got %.0f", count)
}
// ── Folders ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/folders", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Folders: expected 200, got %d", w.Code)
}
var folders []interface{}
json.Unmarshal(w.Body.Bytes(), &folders)
if len(folders) != 2 {
t.Errorf("Folders: expected 2 folders, got %d", len(folders))
}
// ── Update (replace) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/notes/"+noteID,
strings.NewReader(`{"title":"Updated Meeting Notes","content":"New content","mode":"replace"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Update: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Verify update
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
json.Unmarshal(w.Body.Bytes(), &got)
if got["title"] != "Updated Meeting Notes" {
t.Errorf("Update title: got %v", got["title"])
}
if got["content"] != "New content" {
t.Errorf("Update content: got %v", got["content"])
}
// ── Update (append) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/notes/"+noteID,
strings.NewReader(`{"content":"\nAppended line","mode":"append"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Append: expected 200, got %d: %s", w.Code, w.Body.String())
}
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
json.Unmarshal(w.Body.Bytes(), &got)
content := got["content"].(string)
if !strings.Contains(content, "New content") || !strings.Contains(content, "Appended line") {
t.Errorf("Append: expected both parts in content, got %q", content)
}
// ── Delete ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Delete: expected 200, got %d", w.Code)
}
// Verify gone
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Get after delete: expected 404, got %d", w.Code)
}
}
// ── Notes: Search empty query ───────────────
func TestNoteSearchEmptyQuery(t *testing.T) {
database.RequireTestDB(t)
h := NewNoteHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "test-user")
c.Request = httptest.NewRequest("GET", "/api/v1/notes/search", nil)
h.Search(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Search with no query: expected 400, got %d", w.Code)
}
}
// ── Notes: Cross-user isolation ─────────────
func TestNoteIsolationBetweenUsers(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userA := database.SeedTestUser(t, "alice", "alice@test.com")
userB := database.SeedTestUser(t, "bob", "bob@test.com")
h := NewNoteHandler()
// Alice creates a note
rA := gin.New()
rA.Use(func(c *gin.Context) { c.Set("user_id", userA); c.Next() })
rA.POST("/notes", h.Create)
rA.GET("/notes", h.List)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/notes",
strings.NewReader(`{"title":"Alice Secret","content":"private stuff"}`))
req.Header.Set("Content-Type", "application/json")
rA.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("Alice create: expected 201, got %d", w.Code)
}
// Bob lists — should see zero
rB := gin.New()
rB.Use(func(c *gin.Context) { c.Set("user_id", userB); c.Next() })
rB.GET("/notes", h.List)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes", nil)
rB.ServeHTTP(w, req)
var listResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &listResp)
total := listResp["total"].(float64)
if total != 0 {
t.Errorf("Bob should see 0 notes, got %.0f", total)
}
// Alice lists — should see one
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes", nil)
rA.ServeHTTP(w, req)
json.Unmarshal(w.Body.Bytes(), &listResp)
total = listResp["total"].(float64)
if total != 1 {
t.Errorf("Alice should see 1 note, got %.0f", total)
}
}

View File

@@ -1,646 +1,286 @@
package handlers
import (
"database/sql"
"log"
"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"
)
// PresetHandler handles model preset CRUD operations.
type PresetHandler struct{}
// NewPresetHandler creates a new handler.
func NewPresetHandler() *PresetHandler {
return &PresetHandler{}
// PersonaHandler handles persona (formerly preset) endpoints.
type PersonaHandler struct {
stores store.Stores
}
// ── Request/Response Types ─────────────────
type createPresetRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
BaseModelID string `json:"base_model_id" binding:"required"`
APIConfigID *string `json:"api_config_id,omitempty"`
SystemPrompt string `json:"system_prompt"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
ToolsEnabled string `json:"tools_enabled,omitempty"`
Icon string `json:"icon,omitempty"`
IsShared bool `json:"is_shared"`
func NewPersonaHandler(s store.Stores) *PersonaHandler {
return &PersonaHandler{stores: s}
}
type updatePresetRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
BaseModelID *string `json:"base_model_id,omitempty"`
APIConfigID *string `json:"api_config_id,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
ToolsEnabled *string `json:"tools_enabled,omitempty"`
Icon *string `json:"icon,omitempty"`
IsShared *bool `json:"is_shared,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
// ── User Personas (personal scope) ──────────
type presetResponse struct {
models.ModelPreset
ProviderName string `json:"provider_name,omitempty"`
BaseModelName string `json:"base_model_name,omitempty"`
}
// ── User Preset Endpoints ──────────────────
// These require user_providers_enabled for personal presets.
// ListUserPresets returns all presets visible to the user:
// their own personal presets + all global presets + shared presets + team presets.
// GET /api/v1/presets
func (h *PresetHandler) ListUserPresets(c *gin.Context) {
func (h *PersonaHandler) ListUserPersonas(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.Query(`
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled,
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
COALESCE(ac.name, '') as provider_name
FROM model_presets mp
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
WHERE mp.is_active = true
AND (
mp.scope = 'global'
OR (mp.scope = 'personal' AND mp.created_by = $1)
OR (mp.scope = 'personal' AND mp.is_shared = true)
OR (mp.scope = 'team' AND mp.team_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
)
ORDER BY mp.scope ASC, mp.name ASC
`, userID)
personas, err := h.stores.Personas.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list presets"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"})
return
}
defer rows.Close()
presets := make([]presetResponse, 0)
for rows.Next() {
var p presetResponse
if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
); err != nil {
continue
}
presets = append(presets, p)
}
c.JSON(http.StatusOK, gin.H{"presets": presets})
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
}
// CreateUserPreset creates a personal preset for the current user.
// POST /api/v1/presets
func (h *PresetHandler) CreateUserPreset(c *gin.Context) {
func (h *PersonaHandler) CreateUserPersona(c *gin.Context) {
userID := getUserID(c)
var req createPresetRequest
// Check policy
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_personas")
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "custom personas not allowed"})
return
}
var req personaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
persona := req.toPersona()
persona.Scope = models.ScopePersonal
persona.OwnerID = &userID
persona.CreatedBy = userID
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"})
return
}
// Validate api_config_id belongs to user if provided
if req.APIConfigID != nil && *req.APIConfigID != "" {
var count int
err := database.DB.QueryRow(`
SELECT COUNT(*) FROM api_configs
WHERE id = $1 AND (user_id = $2 OR is_global = true) AND is_active = true AND team_id IS NULL
`, *req.APIConfigID, userID).Scan(&count)
if err != nil || count == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or inaccessible API config"})
return
}
c.JSON(http.StatusCreated, persona)
}
func (h *PersonaHandler) UpdateUserPersona(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Personas.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
toolsJSON := req.ToolsEnabled
if toolsJSON == "" {
toolsJSON = "[]"
// Users can only edit their own personal personas
if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot edit this persona"})
return
}
var id string
var patch models.PersonaPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona updated"})
}
func (h *PersonaHandler) DeleteUserPersona(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Personas.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete this persona"})
return
}
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
}
// ── Team Personas ───────────────────────────
func (h *PersonaHandler) ListTeamPersonas(c *gin.Context) {
teamID := c.Param("teamId")
personas, err := h.stores.Personas.ListForTeam(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team personas"})
return
}
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
}
func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) {
userID := getUserID(c)
teamID := c.Param("teamId")
var req personaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
persona := req.toPersona()
persona.Scope = models.ScopeTeam
persona.OwnerID = &teamID
persona.CreatedBy = userID
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team persona"})
return
}
c.JSON(http.StatusCreated, persona)
}
func (h *PersonaHandler) DeleteTeamPersona(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
}
// ── Admin Personas (global scope) ───────────
func (h *PersonaHandler) ListAdminPersonas(c *gin.Context) {
personas, err := h.stores.Personas.ListGlobal(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"})
return
}
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
}
func (h *PersonaHandler) CreateAdminPersona(c *gin.Context) {
userID := getUserID(c)
var req personaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
persona := req.toPersona()
persona.Scope = models.ScopeGlobal
persona.CreatedBy = userID
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"})
return
}
c.JSON(http.StatusCreated, persona)
}
func (h *PersonaHandler) UpdateAdminPersona(c *gin.Context) {
id := c.Param("id")
var patch models.PersonaPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona updated"})
}
func (h *PersonaHandler) DeleteAdminPersona(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
}
// ── Request Types ───────────────────────────
type personaRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
BaseModelID string `json:"base_model_id,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
ThinkingBudget *int `json:"thinking_budget,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
IsShared bool `json:"is_shared,omitempty"`
}
func (r *personaRequest) toPersona() *models.Persona {
p := &models.Persona{
Name: r.Name,
Description: r.Description,
Icon: r.Icon,
BaseModelID: r.BaseModelID,
ProviderConfigID: r.ProviderConfigID,
SystemPrompt: r.SystemPrompt,
Temperature: r.Temperature,
MaxTokens: r.MaxTokens,
ThinkingBudget: r.ThinkingBudget,
TopP: r.TopP,
IsActive: true,
IsShared: r.IsShared,
}
return p
}
// ResolvePreset loads a persona by ID and returns it if the user has access.
// Returns nil if not found, inactive, or not accessible.
// Used by completion.go and messages.go for preset unwrapping.
func ResolvePreset(presetID, userID string) *models.Persona {
var p models.Persona
var providerConfigID *string
err := database.DB.QueryRow(`
INSERT INTO model_presets (name, description, base_model_id, api_config_id,
system_prompt, temperature, max_tokens, tools_enabled,
scope, created_by, is_shared, icon)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'personal', $9, $10, $11)
RETURNING id
`, req.Name, req.Description, req.BaseModelID, req.APIConfigID,
req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON,
userID, req.IsShared, req.Icon,
).Scan(&id)
if err != nil {
log.Printf("[WARN] Failed to create user preset: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create preset: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id})
}
// UpdateUserPreset updates a personal preset owned by the current user.
// PUT /api/v1/presets/:id
func (h *PresetHandler) UpdateUserPreset(c *gin.Context) {
userID := getUserID(c)
presetID := c.Param("id")
// Verify ownership
var createdBy, scope string
err := database.DB.QueryRow(
`SELECT created_by, scope FROM model_presets WHERE id = $1`, presetID,
).Scan(&createdBy, &scope)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
return
}
if scope != models.PresetScopePersonal || createdBy != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only edit your own personal presets"})
return
}
var req updatePresetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Build dynamic SET clause
sets := []string{}
args := []interface{}{}
argN := 1
addField := func(col string, val interface{}) {
sets = append(sets, col+" = $"+itoa(argN))
args = append(args, val)
argN++
}
if req.Name != nil {
addField("name", strings.TrimSpace(*req.Name))
}
if req.Description != nil {
addField("description", *req.Description)
}
if req.BaseModelID != nil {
addField("base_model_id", *req.BaseModelID)
}
if req.APIConfigID != nil {
addField("api_config_id", *req.APIConfigID)
}
if req.SystemPrompt != nil {
addField("system_prompt", *req.SystemPrompt)
}
if req.Temperature != nil {
addField("temperature", *req.Temperature)
}
if req.MaxTokens != nil {
addField("max_tokens", *req.MaxTokens)
}
if req.Icon != nil {
addField("icon", *req.Icon)
}
if req.IsShared != nil {
addField("is_shared", *req.IsShared)
}
if req.IsActive != nil {
addField("is_active", *req.IsActive)
}
if len(sets) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
sets = append(sets, "updated_at = NOW()")
args = append(args, presetID)
query := "UPDATE model_presets SET " + strings.Join(sets, ", ") + " WHERE id = $" + itoa(argN)
_, err = database.DB.Exec(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update preset"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated"})
}
// DeleteUserPreset deletes a personal preset owned by the current user.
// DELETE /api/v1/presets/:id
func (h *PresetHandler) DeleteUserPreset(c *gin.Context) {
userID := getUserID(c)
presetID := c.Param("id")
result, err := database.DB.Exec(`
DELETE FROM model_presets WHERE id = $1 AND created_by = $2 AND scope = 'personal'
`, presetID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preset"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found or not yours"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
// ── Admin Preset Endpoints ─────────────────
// ListAdminPresets returns all presets (any scope) for admin management.
// GET /api/v1/admin/presets
func (h *PresetHandler) ListAdminPresets(c *gin.Context) {
rows, err := database.DB.Query(`
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled,
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
COALESCE(ac.name, '') as provider_name,
COALESCE(u.username, '') as creator_name,
COALESCE(t.name, '') as team_name
FROM model_presets mp
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
LEFT JOIN users u ON mp.created_by = u.id
LEFT JOIN teams t ON mp.team_id = t.id
ORDER BY mp.scope ASC, mp.name ASC
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list presets"})
return
}
defer rows.Close()
type adminPreset struct {
presetResponse
CreatorName string `json:"creator_name"`
TeamName string `json:"team_name"`
}
presets := make([]adminPreset, 0)
for rows.Next() {
var p adminPreset
if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
&p.CreatorName, &p.TeamName,
); err != nil {
continue
}
presets = append(presets, p)
}
c.JSON(http.StatusOK, gin.H{"presets": presets})
}
// CreateAdminPreset creates a global preset (admin-managed, visible to all users).
// POST /api/v1/admin/presets
func (h *PresetHandler) CreateAdminPreset(c *gin.Context) {
userID := getUserID(c)
var req createPresetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
// Validate api_config_id is a global config
if req.APIConfigID != nil && *req.APIConfigID != "" {
var count int
err := database.DB.QueryRow(`
SELECT COUNT(*) FROM api_configs
WHERE id = $1 AND is_global = true AND is_active = true AND team_id IS NULL
`, *req.APIConfigID).Scan(&count)
if err != nil || count == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "global presets must use a global API config"})
return
}
}
toolsJSON := req.ToolsEnabled
if toolsJSON == "" {
toolsJSON = "[]"
}
var id string
err := database.DB.QueryRow(`
INSERT INTO model_presets (name, description, base_model_id, api_config_id,
system_prompt, temperature, max_tokens, tools_enabled,
scope, created_by, is_shared, icon)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'global', $9, true, $10)
RETURNING id
`, req.Name, req.Description, req.BaseModelID, req.APIConfigID,
req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON,
userID, req.Icon,
).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create preset: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id})
AuditLog(c, "preset.create", "preset", id, map[string]interface{}{
"name": req.Name, "scope": "global", "base_model": req.BaseModelID,
})
}
// UpdateAdminPreset updates any preset (admin can edit global and personal).
// PUT /api/v1/admin/presets/:id
func (h *PresetHandler) UpdateAdminPreset(c *gin.Context) {
presetID := c.Param("id")
var req updatePresetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sets := []string{}
args := []interface{}{}
argN := 1
addField := func(col string, val interface{}) {
sets = append(sets, col+" = $"+itoa(argN))
args = append(args, val)
argN++
}
if req.Name != nil {
addField("name", strings.TrimSpace(*req.Name))
}
if req.Description != nil {
addField("description", *req.Description)
}
if req.BaseModelID != nil {
addField("base_model_id", *req.BaseModelID)
}
if req.APIConfigID != nil {
addField("api_config_id", *req.APIConfigID)
}
if req.SystemPrompt != nil {
addField("system_prompt", *req.SystemPrompt)
}
if req.Temperature != nil {
addField("temperature", *req.Temperature)
}
if req.MaxTokens != nil {
addField("max_tokens", *req.MaxTokens)
}
if req.Icon != nil {
addField("icon", *req.Icon)
}
if req.IsShared != nil {
addField("is_shared", *req.IsShared)
}
if req.IsActive != nil {
addField("is_active", *req.IsActive)
}
if len(sets) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
sets = append(sets, "updated_at = NOW()")
args = append(args, presetID)
query := "UPDATE model_presets SET " + strings.Join(sets, ", ") + " WHERE id = $" + itoa(argN)
result, err := database.DB.Exec(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update preset"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated"})
}
// DeleteAdminPreset deletes any preset.
// DELETE /api/v1/admin/presets/:id
func (h *PresetHandler) DeleteAdminPreset(c *gin.Context) {
presetID := c.Param("id")
result, err := database.DB.Exec(`DELETE FROM model_presets WHERE id = $1`, presetID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preset"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
// ── Helpers ────────────────────────────────
// isUserProvidersEnabled checks the global setting.
func isUserProvidersEnabled() bool {
var val string
err := database.DB.QueryRow(
`SELECT value FROM global_settings WHERE key = 'user_providers_enabled'`,
).Scan(&val)
if err != nil {
return true // default: enabled
}
return val == "true"
}
// itoa is a minimal int-to-string for building SQL arg placeholders.
func itoa(n int) string {
if n < 10 {
return string(rune('0' + n))
}
return itoa(n/10) + string(rune('0'+n%10))
}
// ── Preset Resolution for Completion ───────
// ResolvePreset loads a preset by ID and returns its config overrides.
// Returns nil if preset not found or inactive.
func ResolvePreset(presetID, userID string) *models.ModelPreset {
var p models.ModelPreset
err := database.DB.QueryRow(`
SELECT id, name, base_model_id, api_config_id, system_prompt,
temperature, max_tokens, scope, created_by, is_active
FROM model_presets
SELECT id, name, base_model_id, provider_config_id, system_prompt,
temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active
FROM personas
WHERE id = $1 AND is_active = true
AND (
scope = 'global'
OR (scope = 'personal' AND created_by = $2)
OR (scope = 'personal' AND is_shared = true)
OR (scope = 'team' AND team_id IN (
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $2
))
)
`, presetID, userID).Scan(
&p.ID, &p.Name, &p.BaseModelID, &p.APIConfigID, &p.SystemPrompt,
&p.Temperature, &p.MaxTokens, &p.Scope, &p.CreatedBy, &p.IsActive,
&p.ID, &p.Name, &p.BaseModelID, &providerConfigID, &p.SystemPrompt,
&p.Temperature, &p.MaxTokens, &p.ThinkingBudget, &p.TopP,
&p.Scope, &p.OwnerID, &p.CreatedBy, &p.IsActive,
)
if err != nil {
return nil
}
p.ProviderConfigID = providerConfigID
return &p
}
// ── Team Preset Endpoints ─────────────────
// Team admins can create/manage presets scoped to their team.
type createTeamPresetRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
BaseModelID string `json:"base_model_id" binding:"required"`
APIConfigID *string `json:"api_config_id,omitempty"`
SystemPrompt string `json:"system_prompt"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
Icon string `json:"icon,omitempty"`
}
// ListTeamPresets returns presets scoped to a team.
// GET /api/v1/teams/:teamId/presets
func (h *PresetHandler) ListTeamPresets(c *gin.Context) {
teamID := getTeamID(c)
rows, err := database.DB.Query(`
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled,
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
COALESCE(ac.name, '') as provider_name
FROM model_presets mp
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
WHERE mp.team_id = $1 AND mp.scope = 'team'
ORDER BY mp.name ASC
`, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team presets"})
return
}
defer rows.Close()
presets := make([]presetResponse, 0)
for rows.Next() {
var p presetResponse
if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
); err != nil {
continue
}
presets = append(presets, p)
}
c.JSON(http.StatusOK, gin.H{"presets": presets})
}
// CreateTeamPreset creates a preset scoped to a team.
// POST /api/v1/teams/:teamId/presets
func (h *PresetHandler) CreateTeamPreset(c *gin.Context) {
teamID := getTeamID(c)
userID := getUserID(c)
var req createTeamPresetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
toolsJSON := "[]"
var id string
err := database.DB.QueryRow(`
INSERT INTO model_presets (name, description, base_model_id, api_config_id,
system_prompt, temperature, max_tokens, tools_enabled,
scope, team_id, created_by, is_shared, icon)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'team', $9, $10, true, $11)
RETURNING id
`, req.Name, req.Description, req.BaseModelID, req.APIConfigID,
req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON,
teamID, userID, req.Icon,
).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team preset: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id})
AuditLog(c, "preset.create", "preset", id, map[string]interface{}{
"name": req.Name, "scope": "team", "team_id": teamID, "base_model": req.BaseModelID,
})
}
// DeleteTeamPreset deletes a team-scoped preset.
// DELETE /api/v1/teams/:teamId/presets/:id
func (h *PresetHandler) DeleteTeamPreset(c *gin.Context) {
teamID := getTeamID(c)
presetID := c.Param("id")
res, err := database.DB.Exec(`
DELETE FROM model_presets WHERE id = $1 AND team_id = $2 AND scope = 'team'
`, presetID, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "preset.delete", "preset", presetID, map[string]interface{}{
"scope": "team", "team_id": teamID,
})
}

View File

@@ -8,22 +8,23 @@ 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"
)
// ── Team Provider Handlers ──────────────────
// ListTeamProviders returns API configs scoped to a team.
// GET /api/v1/teams/:teamId/providers
func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
teamID := getTeamID(c)
rows, err := database.DB.Query(`
SELECT id, name, provider, endpoint, api_key_encrypted,
SELECT id, name, provider, endpoint, api_key_enc,
model_default, config::text, is_active, is_private, created_at, updated_at
FROM api_configs
WHERE team_id = $1
FROM provider_configs
WHERE scope = 'team' AND owner_id = $1
ORDER BY name ASC
`, teamID)
if err != nil {
@@ -67,17 +68,23 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
}
// CreateTeamProvider creates an API config scoped to a team.
// POST /api/v1/teams/:teamId/providers
func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
// Check allow_team_providers setting
if !isTeamProvidersAllowed(teamID) {
c.JSON(http.StatusForbidden, gin.H{"error": "team providers are not enabled for this team"})
return
}
var req createAPIConfigRequest
var req struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required"`
Endpoint string `json:"endpoint" binding:"required"`
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
IsPrivate bool `json:"is_private,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -99,8 +106,8 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
var id string
err := database.DB.QueryRow(`
INSERT INTO api_configs (team_id, name, provider, endpoint, api_key_encrypted, model_default, config, is_private)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc, model_default, config, is_private)
VALUES ('team', $1, $2, $3, $4, $5, $6, $7::jsonb, $8)
RETURNING id
`, teamID, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault, configJSON, req.IsPrivate,
).Scan(&id)
@@ -114,12 +121,19 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
}
// UpdateTeamProvider updates a team-scoped API config.
// PUT /api/v1/teams/:teamId/providers/:id
func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
var req updateAPIConfigRequest
var req struct {
Name *string `json:"name,omitempty"`
Endpoint *string `json:"endpoint,omitempty"`
APIKey *string `json:"api_key,omitempty"`
ModelDefault *string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -127,14 +141,14 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
// Verify provider belongs to this team
var count int
database.DB.QueryRow(`SELECT COUNT(*) FROM api_configs WHERE id = $1 AND team_id = $2`, providerID, teamID).Scan(&count)
database.DB.QueryRow(`SELECT COUNT(*) FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2`, providerID, teamID).Scan(&count)
if count == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"})
return
}
// Build dynamic update
query := "UPDATE api_configs SET updated_at = NOW()"
query := "UPDATE provider_configs SET updated_at = NOW()"
args := []interface{}{}
argN := 1
@@ -149,7 +163,7 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
argN++
}
if req.APIKey != nil {
query += ", api_key_encrypted = $" + strconv.Itoa(argN)
query += ", api_key_enc = $" + strconv.Itoa(argN)
args = append(args, *req.APIKey)
argN++
}
@@ -175,7 +189,7 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
argN++
}
query += " WHERE id = $" + strconv.Itoa(argN) + " AND team_id = $" + strconv.Itoa(argN+1)
query += " WHERE id = $" + strconv.Itoa(argN) + " AND scope = 'team' AND owner_id = $" + strconv.Itoa(argN+1)
args = append(args, providerID, teamID)
_, err := database.DB.Exec(query, args...)
@@ -188,13 +202,12 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
}
// DeleteTeamProvider removes a team-scoped API config.
// DELETE /api/v1/teams/:teamId/providers/:id
func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
result, err := database.DB.Exec(`
DELETE FROM api_configs WHERE id = $1 AND team_id = $2
DELETE FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2
`, providerID, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete provider"})
@@ -211,7 +224,6 @@ func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) {
}
// ListTeamProviderModels lists models available from a team provider (live query).
// GET /api/v1/teams/:teamId/providers/:id/models
func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
@@ -220,9 +232,9 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
var apiKey *string
var headersJSON []byte
err := database.DB.QueryRow(`
SELECT name, provider, endpoint, api_key_encrypted, custom_headers
FROM api_configs
WHERE id = $1 AND team_id = $2 AND is_active = true
SELECT name, provider, endpoint, api_key_enc, headers
FROM provider_configs
WHERE id = $1 AND scope = 'team' AND owner_id = $2 AND is_active = true
`, providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKey, &headersJSON)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
@@ -254,24 +266,38 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
}
type modelInfo struct {
ID string `json:"id"`
Capabilities providers.ModelCapabilities `json:"capabilities"`
ID string `json:"id"`
Capabilities models.ModelCapabilities `json:"capabilities"`
}
models := make([]modelInfo, 0, len(modelList))
out := make([]modelInfo, 0, len(modelList))
for _, m := range modelList {
caps := providers.MergeCapabilities(m.Capabilities, m.ID)
caps.MaxOutputTokens = providers.ResolveMaxOutput(m.ID, caps)
models = append(models, modelInfo{ID: m.ID, Capabilities: caps})
caps := capspkg.ResolveIntrinsic(m.ID, &m.Capabilities)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(m.ID, caps)
out = append(out, modelInfo{ID: m.ID, Capabilities: caps})
}
c.JSON(http.StatusOK, gin.H{"models": models, "provider": name})
c.JSON(http.StatusOK, gin.H{"models": out, "provider": name})
}
// isTeamProvidersAllowed checks if team providers are enabled for a team.
// First checks the global allow_team_providers setting, then team.settings JSONB.
// parseJSONBConfig parses a JSONB text string into a map.
func parseJSONBConfig(raw string) map[string]interface{} {
if raw == "" || raw == "{}" || raw == "null" {
return map[string]interface{}{}
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return map[string]interface{}{}
}
return m
}
// isTeamProvidersAllowed checks if team providers are enabled.
func isTeamProvidersAllowed(teamID string) bool {
// Check global setting
if database.DB == nil {
return false
}
var globalVal string
err := database.DB.QueryRow(`
SELECT value FROM global_settings WHERE key = 'allow_team_providers'
@@ -279,15 +305,11 @@ func isTeamProvidersAllowed(teamID string) bool {
if err == nil && globalVal == "false" {
return false
}
// Default to true if not set
// Check team-level override
var settingsJSON []byte
err = database.DB.QueryRow(`
SELECT settings FROM teams WHERE id = $1
`, teamID).Scan(&settingsJSON)
err = database.DB.QueryRow(`SELECT settings FROM teams WHERE id = $1`, teamID).Scan(&settingsJSON)
if err != nil {
return true // default allow
return true
}
var settings map[string]interface{}

View File

@@ -478,14 +478,14 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
models := make([]availableModel, 0)
// ── 1. Global admin models (synced in model_configs) ──
// ── 1. Global admin models (synced in model_catalog) ──
rows, err := database.DB.Query(`
SELECT mc.id, mc.model_id, mc.display_name, mc.visibility,
ac.provider, ac.name as provider_name
FROM model_configs mc
JOIN api_configs ac ON mc.api_config_id = ac.id
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.is_global = true
AND ac.is_active = true AND ac.scope = 'global'
ORDER BY ac.name, mc.model_id
`)
if err != nil {
@@ -506,9 +506,9 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
// ── 2. Team provider models (live query) ──
teamRows, err := database.DB.Query(`
SELECT id, name, provider, endpoint, api_key_encrypted, custom_headers
FROM api_configs
WHERE team_id = $1 AND is_active = true
SELECT id, name, provider, endpoint, api_key_enc, headers
FROM provider_configs
WHERE scope = 'team' AND owner_id = $1 AND is_active = true
`, teamID)
if err == nil {
defer teamRows.Close()
@@ -621,7 +621,7 @@ func enforcePrivateProviderPolicy(userID, configID string) error {
// User is in a restricted team — verify the config is private
var isPrivate bool
err = database.DB.QueryRow(`
SELECT COALESCE(is_private, false) FROM api_configs WHERE id = $1
SELECT COALESCE(is_private, false) FROM provider_configs WHERE id = $1
`, configID).Scan(&isPrivate)
if err != nil {
return nil // config lookup failed, allow (fail open)

View File

@@ -11,6 +11,8 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
_ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init()
)
@@ -20,6 +22,8 @@ func main() {
// Register LLM providers
providers.Init()
var stores store.Stores
if err := database.Connect(cfg); err != nil {
log.Printf("⚠ Database unavailable: %v", err)
log.Println(" Running in unmanaged mode (no persistence)")
@@ -28,8 +32,12 @@ func main() {
if err := database.Migrate(); err != nil {
log.Fatalf("❌ Schema migration failed: %v", err)
}
// Initialize store layer
stores = postgres.NewStores(database.DB)
// Bootstrap admin from env (K8s secret) — upserts on every restart
handlers.BootstrapAdmin(cfg)
handlers.BootstrapAdmin(cfg, stores)
}
defer database.Close()
@@ -37,7 +45,6 @@ func main() {
r.Use(middleware.CORS())
// ── Base path group ──────────────────────
// All routes live under cfg.BasePath (e.g. "/dev", "/test", or "")
base := r.Group(cfg.BasePath)
// ── EventBus + WebSocket Hub ─────────────
@@ -54,11 +61,11 @@ func main() {
})
})
// WebSocket endpoint — auth via ?token= query param
// WebSocket endpoint
base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
// ── Auth routes (rate limited) ──────────────
auth := handlers.NewAuthHandler(cfg)
auth := handlers.NewAuthHandler(cfg, stores)
authLimiter := middleware.NewRateLimiter(1, 5)
api := base.Group("/api/v1")
@@ -72,9 +79,8 @@ func main() {
"database": database.IsConnected(),
"providers": providers.List(),
}
// Include registration status for frontend
if database.IsConnected() {
info["registration_enabled"] = isRegistrationOpen()
info["registration_enabled"] = handlers.IsRegistrationEnabled(stores)
}
c.JSON(200, info)
})
@@ -92,7 +98,7 @@ func main() {
protected := api.Group("")
protected.Use(middleware.Auth(cfg))
{
// Channels (unified: replaces /chats)
// Channels
channels := handlers.NewChannelHandler()
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
@@ -116,21 +122,26 @@ func main() {
comp := handlers.NewCompletionHandler()
protected.POST("/chat/completions", comp.Complete)
// API Configs
apiCfg := handlers.NewAPIConfigHandler()
protected.GET("/api-configs", apiCfg.ListConfigs)
protected.POST("/api-configs", apiCfg.CreateConfig)
protected.GET("/api-configs/:id", apiCfg.GetConfig)
protected.PUT("/api-configs/:id", apiCfg.UpdateConfig)
protected.DELETE("/api-configs/:id", apiCfg.DeleteConfig)
// Provider Configs (user-facing — replaces /api-configs)
provCfg := handlers.NewProviderConfigHandler(stores)
protected.GET("/api-configs", provCfg.ListConfigs) // backward compat
protected.POST("/api-configs", provCfg.CreateConfig)
protected.GET("/api-configs/:id", provCfg.GetConfig)
protected.PUT("/api-configs/:id", provCfg.UpdateConfig)
protected.DELETE("/api-configs/:id", provCfg.DeleteConfig)
protected.GET("/api-configs/:id/models", provCfg.ListModels)
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
// Models (per-config and aggregate)
protected.GET("/api-configs/:id/models", apiCfg.ListModels)
protected.GET("/models", apiCfg.ListAllModels)
protected.GET("/models/enabled", apiCfg.ListEnabledModels)
protected.GET("/models/preferences", handlers.GetModelPreferences)
protected.PUT("/models/preferences", handlers.SetModelPreference)
protected.POST("/models/preferences/bulk", handlers.BulkSetModelPreferences)
// Models (unified resolver — replaces scattered endpoints)
modelH := handlers.NewModelHandler(stores)
protected.GET("/models/enabled", modelH.ListEnabledModels)
protected.GET("/models", modelH.ListEnabledModels) // alias
// Model Preferences
modelPrefs := handlers.NewModelPrefsHandler(stores)
protected.GET("/models/preferences", modelPrefs.GetPreferences)
protected.PUT("/models/preferences", modelPrefs.SetPreference)
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
// User Settings & Profile
settings := handlers.NewSettingsHandler()
@@ -142,24 +153,31 @@ func main() {
protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings)
// Model Presets (user)
presets := handlers.NewPresetHandler()
protected.GET("/presets", presets.ListUserPresets)
protected.POST("/presets", presets.CreateUserPreset)
protected.PUT("/presets/:id", presets.UpdateUserPreset)
protected.DELETE("/presets/:id", presets.DeleteUserPreset)
// Personas (replaces /presets)
personas := handlers.NewPersonaHandler(stores)
protected.GET("/presets", personas.ListUserPersonas) // backward compat
protected.POST("/presets", personas.CreateUserPersona)
protected.PUT("/presets/:id", personas.UpdateUserPersona)
protected.DELETE("/presets/:id", personas.DeleteUserPersona)
protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
// Notes
notes := handlers.NewNoteHandler()
protected.GET("/notes", notes.List)
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/folders", notes.ListFolders)
protected.POST("/notes/bulk-delete", notes.BulkDelete)
protected.GET("/notes/:id", notes.Get)
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
// Teams (user: my teams)
teams := handlers.NewTeamHandler()
protected.GET("/teams/mine", teams.MyTeams)
// Team admin self-service (requires team admin role, not sys-admin)
// Team admin self-service
teamScoped := protected.Group("/teams/:teamId")
teamScoped.Use(middleware.RequireTeamAdmin())
{
@@ -176,22 +194,15 @@ func main() {
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
// Team presets
teamPresets := handlers.NewPresetHandler()
teamScoped.GET("/presets", teamPresets.ListTeamPresets)
teamScoped.POST("/presets", teamPresets.CreateTeamPreset)
teamScoped.DELETE("/presets/:id", teamPresets.DeleteTeamPreset)
// Team personas
teamPersonas := handlers.NewPersonaHandler(stores)
teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
teamScoped.POST("/presets", teamPersonas.CreateTeamPersona)
teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona)
}
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/folders", notes.ListFolders)
protected.POST("/notes/bulk-delete", notes.BulkDelete)
protected.GET("/notes/:id", notes.Get)
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
// Public global settings (non-admin users can read safe subset)
adm := handlers.NewAdminHandler()
adm := handlers.NewAdminHandler(stores)
protected.GET("/settings/public", adm.PublicSettings)
}
@@ -200,7 +211,7 @@ func main() {
admin.Use(middleware.Auth(cfg))
admin.Use(middleware.RequireAdmin())
{
adm := handlers.NewAdminHandler()
adm := handlers.NewAdminHandler(stores)
// User management
admin.GET("/users", adm.ListUsers)
@@ -218,35 +229,31 @@ func main() {
// Stats
admin.GET("/stats", adm.GetStats)
// Global API Configs
// Global Provider Configs
admin.GET("/configs", adm.ListGlobalConfigs)
admin.POST("/configs", adm.CreateGlobalConfig)
admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
// Model Configs
// Model Catalog
admin.GET("/models", adm.ListModelConfigs)
admin.POST("/models/fetch", adm.FetchModels)
admin.PUT("/models/bulk", adm.BulkUpdateModels)
admin.PUT("/models/:id", adm.UpdateModelConfig)
admin.DELETE("/models/:id", adm.DeleteModelConfig)
// Model Presets (admin)
presetAdm := handlers.NewPresetHandler()
admin.GET("/presets", presetAdm.ListAdminPresets)
admin.POST("/presets", presetAdm.CreateAdminPreset)
admin.PUT("/presets/:id", presetAdm.UpdateAdminPreset)
admin.DELETE("/presets/:id", presetAdm.DeleteAdminPreset)
// Personas (admin global)
personaAdm := handlers.NewPersonaHandler(stores)
admin.GET("/presets", personaAdm.ListAdminPersonas)
admin.POST("/presets", personaAdm.CreateAdminPersona)
admin.PUT("/presets/:id", personaAdm.UpdateAdminPersona)
admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona)
admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
// Teams (admin)
teamAdm := handlers.NewTeamHandler()
admin.GET("/teams", teamAdm.ListTeams)
// Audit log
admin.GET("/audit", adm.ListAuditLog)
admin.GET("/audit/actions", adm.ListAuditActions)
admin.POST("/teams", teamAdm.CreateTeam)
admin.GET("/teams/:id", teamAdm.GetTeam)
admin.PUT("/teams/:id", teamAdm.UpdateTeam)
@@ -255,6 +262,10 @@ func main() {
admin.POST("/teams/:id/members", teamAdm.AddMember)
admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember)
admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)
// Audit log
admin.GET("/audit", adm.ListAuditLog)
admin.GET("/audit/actions", adm.ListAuditActions)
}
}
@@ -271,7 +282,3 @@ func main() {
log.Fatalf("Failed to start server: %v", err)
}
}
func isRegistrationOpen() bool {
return handlers.IsRegistrationEnabled()
}

View File

@@ -1,308 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/providers"
)
func init() {
gin.SetMode(gin.TestMode)
providers.Init()
}
func TestHealthEndpoint(t *testing.T) {
r := gin.New()
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok", "version": "test", "database": false})
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/health", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected %d, got %d", http.StatusOK, w.Code)
}
}
func TestCORSHeaders(t *testing.T) {
r := gin.New()
r.Use(middleware.CORS())
r.GET("/test", func(c *gin.Context) { c.Status(200) })
w := httptest.NewRecorder()
req, _ := http.NewRequest("OPTIONS", "/test", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("Expected %d for OPTIONS, got %d", http.StatusNoContent, w.Code)
}
if w.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Error("Missing CORS header")
}
}
func TestAllRoutesRegistered(t *testing.T) {
cfg := &config.Config{JWTSecret: "test"}
auth := handlers.NewAuthHandler(cfg)
channels := handlers.NewChannelHandler()
msgs := handlers.NewMessageHandler()
comp := handlers.NewCompletionHandler()
apiCfg := handlers.NewAPIConfigHandler()
settings := handlers.NewSettingsHandler()
presets := handlers.NewPresetHandler()
notes := handlers.NewNoteHandler()
adm := handlers.NewAdminHandler()
r := gin.New()
api := r.Group("/api/v1")
authGroup := api.Group("/auth")
{
authGroup.POST("/register", auth.Register)
authGroup.POST("/login", auth.Login)
authGroup.POST("/refresh", auth.Refresh)
authGroup.POST("/logout", auth.Logout)
}
protected := api.Group("")
{
// Channels
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
protected.GET("/channels/:id", channels.GetChannel)
protected.PUT("/channels/:id", channels.UpdateChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Messages
protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage)
// Message tree (forking)
protected.GET("/channels/:id/path", msgs.GetActivePath)
protected.PUT("/channels/:id/cursor", msgs.UpdateCursor)
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
// Completion Engine
protected.POST("/chat/completions", comp.Complete)
// API Configs
protected.GET("/api-configs", apiCfg.ListConfigs)
protected.POST("/api-configs", apiCfg.CreateConfig)
protected.GET("/api-configs/:id", apiCfg.GetConfig)
protected.PUT("/api-configs/:id", apiCfg.UpdateConfig)
protected.DELETE("/api-configs/:id", apiCfg.DeleteConfig)
protected.GET("/api-configs/:id/models", apiCfg.ListModels)
protected.GET("/models", apiCfg.ListAllModels)
protected.GET("/models/enabled", apiCfg.ListEnabledModels)
// User Settings & Profile
protected.GET("/profile", settings.GetProfile)
protected.PUT("/profile", settings.UpdateProfile)
protected.POST("/profile/password", settings.ChangePassword)
protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings)
// Model Presets
protected.GET("/presets", presets.ListUserPresets)
protected.POST("/presets", presets.CreateUserPreset)
protected.PUT("/presets/:id", presets.UpdateUserPreset)
protected.DELETE("/presets/:id", presets.DeleteUserPreset)
// Notes
protected.GET("/notes", notes.List)
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/folders", notes.ListFolders)
protected.POST("/notes/bulk-delete", notes.BulkDelete)
protected.GET("/notes/:id", notes.Get)
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
}
// Admin routes
admin := api.Group("/admin")
{
admin.GET("/users", adm.ListUsers)
admin.POST("/users", adm.CreateUser)
admin.PUT("/users/:id/role", adm.UpdateUserRole)
admin.PUT("/users/:id/active", adm.ToggleUserActive)
admin.POST("/users/:id/reset-password", adm.ResetPassword)
admin.DELETE("/users/:id", adm.DeleteUser)
admin.GET("/settings", adm.ListGlobalSettings)
admin.GET("/settings/:key", adm.GetGlobalSetting)
admin.PUT("/settings/:key", adm.UpdateGlobalSetting)
admin.GET("/stats", adm.GetStats)
admin.GET("/configs", adm.ListGlobalConfigs)
admin.POST("/configs", adm.CreateGlobalConfig)
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
admin.GET("/models", adm.ListModelConfigs)
admin.POST("/models/fetch", adm.FetchModels)
admin.PUT("/models/:id", adm.UpdateModelConfig)
admin.DELETE("/models/:id", adm.DeleteModelConfig)
}
routes := r.Routes()
routePaths := make(map[string]bool)
for _, route := range routes {
routePaths[route.Method+" "+route.Path] = true
}
expected := []string{
// Auth
"POST /api/v1/auth/register",
"POST /api/v1/auth/login",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
// Channels
"GET /api/v1/channels",
"POST /api/v1/channels",
"GET /api/v1/channels/:id",
"PUT /api/v1/channels/:id",
"DELETE /api/v1/channels/:id",
// Messages
"GET /api/v1/channels/:id/messages",
"POST /api/v1/channels/:id/messages",
// Message tree (forking)
"GET /api/v1/channels/:id/path",
"PUT /api/v1/channels/:id/cursor",
"POST /api/v1/channels/:id/messages/:msgId/edit",
"POST /api/v1/channels/:id/messages/:msgId/regenerate",
"GET /api/v1/channels/:id/messages/:msgId/siblings",
// Completion Engine
"POST /api/v1/chat/completions",
// API Configs
"GET /api/v1/api-configs",
"POST /api/v1/api-configs",
"GET /api/v1/api-configs/:id",
"PUT /api/v1/api-configs/:id",
"DELETE /api/v1/api-configs/:id",
"GET /api/v1/api-configs/:id/models",
// Models
"GET /api/v1/models",
"GET /api/v1/models/enabled",
// Profile & Settings
"GET /api/v1/profile",
"PUT /api/v1/profile",
"POST /api/v1/profile/password",
"GET /api/v1/settings",
"PUT /api/v1/settings",
// Presets
"GET /api/v1/presets",
"POST /api/v1/presets",
"PUT /api/v1/presets/:id",
"DELETE /api/v1/presets/:id",
// Notes
"GET /api/v1/notes",
"POST /api/v1/notes",
"GET /api/v1/notes/search",
"GET /api/v1/notes/folders",
"POST /api/v1/notes/bulk-delete",
"GET /api/v1/notes/:id",
"PUT /api/v1/notes/:id",
"DELETE /api/v1/notes/:id",
// Admin
"GET /api/v1/admin/users",
"POST /api/v1/admin/users",
"PUT /api/v1/admin/users/:id/role",
"PUT /api/v1/admin/users/:id/active",
"POST /api/v1/admin/users/:id/reset-password",
"DELETE /api/v1/admin/users/:id",
"GET /api/v1/admin/settings",
"GET /api/v1/admin/settings/:key",
"PUT /api/v1/admin/settings/:key",
"GET /api/v1/admin/stats",
"GET /api/v1/admin/configs",
"POST /api/v1/admin/configs",
"DELETE /api/v1/admin/configs/:id",
"GET /api/v1/admin/models",
"POST /api/v1/admin/models/fetch",
"PUT /api/v1/admin/models/:id",
"DELETE /api/v1/admin/models/:id",
}
for _, e := range expected {
if !routePaths[e] {
t.Errorf("Missing route: %s", e)
}
}
}
func TestProviderRegistry(t *testing.T) {
ids := providers.List()
if len(ids) < 2 {
t.Errorf("Expected at least 2 providers, got %d", len(ids))
}
// OpenAI should be registered
p, err := providers.Get("openai")
if err != nil {
t.Errorf("OpenAI provider not found: %v", err)
}
if p.ID() != "openai" {
t.Errorf("Expected ID 'openai', got '%s'", p.ID())
}
// Anthropic should be registered
p, err = providers.Get("anthropic")
if err != nil {
t.Errorf("Anthropic provider not found: %v", err)
}
if p.ID() != "anthropic" {
t.Errorf("Expected ID 'anthropic', got '%s'", p.ID())
}
// Unknown should fail
_, err = providers.Get("nonexistent")
if err == nil {
t.Error("Expected error for unknown provider")
}
}
func TestAnthropicStaticModels(t *testing.T) {
p := &providers.AnthropicProvider{}
models, err := p.ListModels(nil, providers.ProviderConfig{})
if err != nil {
t.Errorf("ListModels should not fail: %v", err)
}
if len(models) == 0 {
t.Error("Expected some static models")
}
// Check that known models are present
found := false
for _, m := range models {
if m.ID == "claude-sonnet-4-20250514" {
found = true
break
}
}
if !found {
t.Error("Expected claude-sonnet-4 in static model list")
}
}
func TestAuthMiddlewareNoDatabase(t *testing.T) {
cfg := &config.Config{JWTSecret: "test"}
r := gin.New()
r.Use(middleware.Auth(cfg))
r.GET("/protected", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/protected", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected pass-through with no DB, got %d", w.Code)
}
}

View File

@@ -1,77 +1,320 @@
package models
import (
"database/sql"
"encoding/json"
"time"
)
// BaseModel contains fields common to all models
// ── Base ────────────────────────────────────
type BaseModel struct {
ID string `json:"id" db:"id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// User represents a user in the system
type User struct {
BaseModel
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"`
Name string `json:"name" db:"name"`
Role string `json:"role" db:"role"`
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
IsActive bool `json:"is_active" db:"is_active"`
}
// ── Scope Constants ─────────────────────────
const (
ScopeGlobal = "global"
ScopeTeam = "team"
ScopePersonal = "personal"
)
// ── Visibility Constants ────────────────────
const (
VisibilityVisible = "visible"
VisibilityHidden = "hidden"
)
// ── Role Constants ──────────────────────────
// UserRole constants
const (
UserRoleUser = "user"
UserRoleAdmin = "admin"
TeamRoleAdmin = "admin"
TeamRoleMember = "member"
)
// ── Channel Types ───────────────────────────
// ── Grant Type Constants ────────────────────
const (
ChannelTypeDirect = "direct" // 1:1 AI chat (legacy "chat")
ChannelTypeGroup = "group" // multi-model conversation
ChannelTypeChannel = "channel" // named, persistent, membered
GrantTypeTool = "tool"
GrantTypeKnowledgeBase = "knowledge_base"
GrantTypeAPIEndpoint = "api_endpoint"
)
// Channel represents a conversation channel (unified: chats + channels).
type Channel struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"`
Description string `json:"description,omitempty" db:"description"`
Type string `json:"type" db:"type"`
Model string `json:"model,omitempty" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
APIConfigID *string `json:"api_config_id,omitempty" db:"api_config_id"`
IsArchived bool `json:"is_archived" db:"is_archived"`
IsPinned bool `json:"is_pinned" db:"is_pinned"`
FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
}
// ── Channel Type Constants ──────────────────
// Message represents a message in a channel
type Message struct {
BaseModel
ChannelID string `json:"channel_id" db:"channel_id"`
Role string `json:"role" db:"role"`
Content string `json:"content" db:"content"`
Tokens int `json:"tokens,omitempty" db:"tokens"`
Model string `json:"model,omitempty" db:"model"`
FinishReason string `json:"finish_reason,omitempty" db:"finish_reason"`
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
}
const (
ChannelTypeDirect = "direct"
ChannelTypeGroup = "group"
ChannelTypeChannel = "channel"
)
// ── Message Role Constants ──────────────────
const (
MessageRoleUser = "user"
MessageRoleAssistant = "assistant"
MessageRoleSystem = "system"
MessageRoleTool = "tool"
)
// ── Channel Members & Models ────────────────
// =========================================
// USERS
// =========================================
type User struct {
BaseModel
Username string `json:"username" db:"username"`
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"`
DisplayName string `json:"display_name,omitempty" db:"display_name"`
AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"`
Role string `json:"role" db:"role"`
IsActive bool `json:"is_active" db:"is_active"`
Settings JSONMap `json:"settings,omitempty" db:"settings"`
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
}
// =========================================
// TEAMS
// =========================================
type Team struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description,omitempty" db:"description"`
CreatedBy string `json:"created_by" db:"created_by"`
IsActive bool `json:"is_active" db:"is_active"`
Settings JSONMap `json:"settings,omitempty" db:"settings"`
MemberCount int `json:"member_count,omitempty"` // computed
}
type TeamMember struct {
ID string `json:"id" db:"id"`
TeamID string `json:"team_id" db:"team_id"`
UserID string `json:"user_id" db:"user_id"`
Role string `json:"role" db:"role"`
JoinedAt string `json:"joined_at" db:"joined_at"`
// Joined fields from users table
Email string `json:"email,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Username string `json:"username,omitempty"`
UserRole string `json:"user_role,omitempty"`
}
// =========================================
// PROVIDER CONFIGS (replaces APIConfig)
// =========================================
type ProviderConfig struct {
BaseModel
Scope string `json:"scope" db:"scope"`
OwnerID *string `json:"owner_id,omitempty" db:"owner_id"`
Name string `json:"name" db:"name"`
Provider string `json:"provider" db:"provider"`
Endpoint string `json:"endpoint" db:"endpoint"`
APIKeyEnc string `json:"-" db:"api_key_enc"`
ModelDefault string `json:"model_default,omitempty" db:"model_default"`
Config JSONMap `json:"config,omitempty" db:"config"`
Headers JSONMap `json:"headers,omitempty" db:"headers"`
Settings JSONMap `json:"settings,omitempty" db:"settings"`
IsActive bool `json:"is_active" db:"is_active"`
IsPrivate bool `json:"is_private" db:"is_private"`
}
type ProviderConfigPatch struct {
Name *string `json:"name,omitempty"`
Endpoint *string `json:"endpoint,omitempty"`
APIKeyEnc *string `json:"-"`
ModelDefault *string `json:"model_default,omitempty"`
Config JSONMap `json:"config,omitempty"`
Headers JSONMap `json:"headers,omitempty"`
Settings JSONMap `json:"settings,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"`
}
// =========================================
// MODEL CATALOG (replaces model_configs)
// =========================================
type CatalogEntry struct {
BaseModel
ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"`
ModelID string `json:"model_id" db:"model_id"`
DisplayName string `json:"display_name,omitempty" db:"display_name"`
Capabilities ModelCapabilities `json:"capabilities" db:"capabilities"`
Pricing *ModelPricing `json:"pricing,omitempty" db:"pricing"`
Visibility string `json:"visibility" db:"visibility"`
LastSyncedAt *time.Time `json:"last_synced_at,omitempty" db:"last_synced_at"`
}
type ModelCapabilities struct {
Streaming bool `json:"streaming"`
ToolCalling bool `json:"tool_calling"`
Vision bool `json:"vision"`
Thinking bool `json:"thinking"`
Reasoning bool `json:"reasoning"`
CodeOptimized bool `json:"code_optimized"`
WebSearch bool `json:"web_search"`
MaxContext int `json:"max_context"`
MaxOutputTokens int `json:"max_output_tokens"`
}
func (c ModelCapabilities) HasProviderData() bool {
return c.ToolCalling || c.Vision || c.Thinking || c.Reasoning ||
c.CodeOptimized || c.WebSearch || c.MaxContext > 0 || c.MaxOutputTokens > 0
}
type ModelPricing struct {
InputPerM float64 `json:"input_per_m,omitempty"`
OutputPerM float64 `json:"output_per_m,omitempty"`
Currency string `json:"currency,omitempty"`
}
// =========================================
// PERSONAS (replaces ModelPreset)
// =========================================
type Persona struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description,omitempty" db:"description"`
Icon string `json:"icon,omitempty" db:"icon"`
Avatar string `json:"avatar,omitempty" db:"avatar"`
BaseModelID string `json:"base_model_id" db:"base_model_id"`
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
Temperature *float64 `json:"temperature,omitempty" db:"temperature"`
MaxTokens *int `json:"max_tokens,omitempty" db:"max_tokens"`
ThinkingBudget *int `json:"thinking_budget,omitempty" db:"thinking_budget"`
TopP *float64 `json:"top_p,omitempty" db:"top_p"`
Scope string `json:"scope" db:"scope"`
OwnerID *string `json:"owner_id,omitempty" db:"owner_id"`
CreatedBy string `json:"created_by" db:"created_by"`
IsActive bool `json:"is_active" db:"is_active"`
IsShared bool `json:"is_shared" db:"is_shared"`
// Loaded from persona_grants, not stored in personas table
Grants []Grant `json:"grants,omitempty" db:"-"`
}
type PersonaPatch struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"`
Avatar *string `json:"avatar,omitempty"`
BaseModelID *string `json:"base_model_id,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
ThinkingBudget *int `json:"thinking_budget,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
IsShared *bool `json:"is_shared,omitempty"`
}
// =========================================
// GRANTS
// =========================================
type Grant struct {
ID string `json:"id" db:"id"`
PersonaID string `json:"persona_id" db:"persona_id"`
GrantType string `json:"grant_type" db:"grant_type"`
GrantRef string `json:"grant_ref" db:"grant_ref"`
Config JSONMap `json:"config,omitempty" db:"config"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// =========================================
// PLATFORM POLICIES
// =========================================
var PolicyDefaults = map[string]string{
"allow_user_byok": "false",
"allow_user_personas": "false",
"allow_raw_model_access": "false",
"allow_registration": "true",
"default_user_active": "false",
"allow_team_providers": "true",
}
// =========================================
// USER MODEL SETTINGS
// =========================================
type UserModelSetting struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
ModelID string `json:"model_id" db:"model_id"`
Hidden bool `json:"hidden" db:"hidden"`
PreferredTemperature *float64 `json:"preferred_temperature,omitempty" db:"preferred_temperature"`
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty" db:"preferred_max_tokens"`
SortOrder int `json:"sort_order" db:"sort_order"`
}
type UserModelSettingPatch struct {
Hidden *bool `json:"hidden,omitempty"`
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}
// =========================================
// CHANNELS
// =========================================
type Channel struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"`
Description string `json:"description,omitempty" db:"description"`
Type string `json:"type" db:"type"`
Model string `json:"model,omitempty" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
IsArchived bool `json:"is_archived" db:"is_archived"`
IsPinned bool `json:"is_pinned" db:"is_pinned"`
FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
Settings JSONMap `json:"settings,omitempty" db:"settings"`
}
// =========================================
// MESSAGES
// =========================================
type Message struct {
BaseModel
ChannelID string `json:"channel_id" db:"channel_id"`
Role string `json:"role" db:"role"`
Content string `json:"content" db:"content"`
Model string `json:"model,omitempty" db:"model"`
TokensUsed int `json:"tokens_used,omitempty" db:"tokens_used"`
ToolCalls JSONMap `json:"tool_calls,omitempty" db:"tool_calls"`
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
SiblingIndex int `json:"sibling_index" db:"sibling_index"`
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
}
// =========================================
// CHANNEL MEMBERS, MODELS, CURSORS
// =========================================
type ChannelMember struct {
ID string `json:"id" db:"id"`
@@ -83,13 +326,13 @@ type ChannelMember struct {
}
type ChannelModel struct {
ID string `json:"id" db:"id"`
ChannelID string `json:"channel_id" db:"channel_id"`
ModelID string `json:"model_id" db:"model_id"`
APIConfigID string `json:"api_config_id,omitempty" db:"api_config_id"`
DisplayName string `json:"display_name,omitempty" db:"display_name"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
IsDefault bool `json:"is_default" db:"is_default"`
ID string `json:"id" db:"id"`
ChannelID string `json:"channel_id" db:"channel_id"`
ModelID string `json:"model_id" db:"model_id"`
ProviderConfigID string `json:"provider_config_id,omitempty" db:"provider_config_id"`
DisplayName string `json:"display_name,omitempty" db:"display_name"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
IsDefault bool `json:"is_default" db:"is_default"`
}
type ChannelCursor struct {
@@ -100,13 +343,16 @@ type ChannelCursor struct {
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ── Organization ────────────────────────────
// =========================================
// ORGANIZATION
// =========================================
type Folder struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
SortOrder int `json:"sort_order" db:"sort_order"`
}
type Project struct {
@@ -117,114 +363,134 @@ type Project struct {
Color string `json:"color,omitempty" db:"color"`
}
// ── API Config ──────────────────────────────
type APIConfig struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Provider string `json:"provider" db:"provider"`
APIKey string `json:"-" db:"api_key"`
BaseURL string `json:"base_url,omitempty" db:"base_url"`
Model string `json:"model" db:"model"`
IsDefault bool `json:"is_default" db:"is_default"`
}
// ── Notes (future Phase 2) ──────────────────
// =========================================
// NOTES
// =========================================
type Note struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
Tags []string `json:"tags,omitempty" db:"tags"`
FolderID string `json:"folder_id,omitempty" db:"folder_id"`
IsShared bool `json:"is_shared" db:"is_shared"`
UserID string `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
FolderPath string `json:"folder_path" db:"folder_path"`
Tags []string `json:"tags,omitempty" db:"tags"`
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
SourceChannelID *string `json:"source_channel_id,omitempty" db:"source_channel_id"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
}
type KnowledgeBase struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Source string `json:"source" db:"source"`
Settings string `json:"settings,omitempty" db:"settings"`
// =========================================
// AUDIT LOG
// =========================================
type AuditEntry struct {
ID string `json:"id" db:"id"`
ActorID *string `json:"actor_id,omitempty" db:"actor_id"`
Action string `json:"action" db:"action"`
ResourceType string `json:"resource_type" db:"resource_type"`
ResourceID string `json:"resource_id,omitempty" db:"resource_id"`
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
IPAddress string `json:"ip_address,omitempty" db:"ip_address"`
UserAgent string `json:"user_agent,omitempty" db:"user_agent"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ── Model Presets ──────────────────────────
// =========================================
// VIEW MODELS (computed, not stored)
// =========================================
const (
PresetScopeGlobal = "global"
PresetScopeTeam = "team"
PresetScopePersonal = "personal"
)
// UserModel is the view model returned by the capability resolver.
// Combines catalog entries + Personas for the frontend.
type UserModel struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
ModelID string `json:"model_id"`
Source string `json:"source"` // "catalog", "persona", "live"
type ModelPreset struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
BaseModelID string `json:"base_model_id" db:"base_model_id"`
APIConfigID *string `json:"api_config_id,omitempty" db:"api_config_id"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
Temperature *float64 `json:"temperature,omitempty" db:"temperature"`
MaxTokens *int `json:"max_tokens,omitempty" db:"max_tokens"`
ToolsEnabled string `json:"tools_enabled,omitempty" db:"tools_enabled"` // JSON array
Scope string `json:"scope" db:"scope"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
CreatedBy string `json:"created_by" db:"created_by"`
IsShared bool `json:"is_shared" db:"is_shared"`
IsActive bool `json:"is_active" db:"is_active"`
Icon string `json:"icon,omitempty" db:"icon"`
Avatar string `json:"avatar,omitempty" db:"avatar"`
ProviderConfigID string `json:"provider_config_id"`
ConfigID string `json:"config_id"` // Alias of ProviderConfigID for frontend compat
ProviderName string `json:"provider_name"`
ProviderType string `json:"provider_type"`
Capabilities ModelCapabilities `json:"capabilities"`
// Preset fields — always emitted so frontend can branch on is_preset.
IsPreset bool `json:"is_preset"`
PresetID string `json:"preset_id,omitempty"`
PresetScope string `json:"preset_scope,omitempty"`
PresetAvatar string `json:"preset_avatar,omitempty"`
PresetTeamName string `json:"preset_team_name,omitempty"`
PersonaID string `json:"persona_id,omitempty"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Avatar string `json:"avatar,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
ToolGrants []string `json:"tool_grants,omitempty"`
Pricing *ModelPricing `json:"pricing,omitempty"`
Scope string `json:"scope"`
OwnerID *string `json:"owner_id,omitempty"`
TeamName string `json:"team_name,omitempty"`
Hidden bool `json:"hidden"`
SortOrder int `json:"sort_order"`
}
// ── Teams ───────────────────────────────────
// =========================================
// JSON HELPERS
// =========================================
const (
TeamRoleAdmin = "admin"
TeamRoleMember = "member"
)
// JSONMap scans from/to JSONB columns.
type JSONMap map[string]interface{}
type Team struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description,omitempty" db:"description"`
CreatedBy string `json:"created_by" db:"created_by"`
IsActive bool `json:"is_active" db:"is_active"`
Settings string `json:"settings,omitempty" db:"settings"` // JSON
MemberCount int `json:"member_count,omitempty"` // computed, not stored
func (m *JSONMap) Scan(src interface{}) error {
if src == nil {
*m = nil
return nil
}
var source []byte
switch v := src.(type) {
case []byte:
source = v
case string:
source = []byte(v)
default:
return nil
}
result := make(JSONMap)
if err := json.Unmarshal(source, &result); err != nil {
return err
}
*m = result
return nil
}
type TeamMember struct {
ID string `json:"id" db:"id"`
TeamID string `json:"team_id" db:"team_id"`
UserID string `json:"user_id" db:"user_id"`
Role string `json:"role" db:"role"`
JoinedAt string `json:"joined_at" db:"joined_at"`
// Joined fields (from user)
Email string `json:"email,omitempty"`
DisplayName string `json:"display_name,omitempty"`
UserRole string `json:"user_role,omitempty"` // system role (user/admin)
func NullString(s *string) sql.NullString {
if s == nil {
return sql.NullString{}
}
return sql.NullString{String: *s, Valid: true}
}
// ── Settings ────────────────────────────────
type Settings struct {
UserID string `json:"user_id" db:"user_id"`
Theme string `json:"theme" db:"theme"`
Language string `json:"language" db:"language"`
Model string `json:"model" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
MaxTokens int `json:"max_tokens" db:"max_tokens"`
Temperature float64 `json:"temperature" db:"temperature"`
DefaultAPIConfigID string `json:"default_api_config_id,omitempty" db:"default_api_config_id"`
func NullFloat(f *float64) sql.NullFloat64 {
if f == nil {
return sql.NullFloat64{}
}
return sql.NullFloat64{Float64: *f, Valid: true}
}
type APIToken struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Token string `json:"-" db:"token"`
ExpiresAt time.Time `json:"expires_at,omitempty" db:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"`
IsActive bool `json:"is_active" db:"is_active"`
func NullInt(i *int) sql.NullInt64 {
if i == nil {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: int64(*i), Valid: true}
}
func StringPtr(s string) *string { return &s }
func Float64Ptr(f float64) *float64 { return &f }
func IntPtr(i int) *int { return &i }
func BoolPtr(b bool) *bool { return &b }

View File

@@ -1,6 +1,8 @@
package providers
import (
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/models"
"bufio"
"bytes"
"context"
@@ -166,17 +168,17 @@ func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]M
{"claude-3-5-haiku-20241022", "Claude 3.5 Haiku"},
}
models := make([]Model, 0, len(modelIDs))
out := make([]Model, 0, len(modelIDs))
for _, m := range modelIDs {
caps, _ := LookupKnownModel(m.id)
models = append(models, Model{
caps, _ := capabilities.LookupKnownModel(m.id)
out = append(out, Model{
ID: m.id,
Name: m.name,
OwnedBy: "anthropic",
Capabilities: caps,
})
}
return models, nil
return out, nil
}
// ── HTTP Layer ──────────────────────────────
@@ -255,7 +257,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
antReq.System = system
}
if antReq.MaxTokens == 0 {
antReq.MaxTokens = ResolveMaxOutput(req.Model, ModelCapabilities{})
antReq.MaxTokens = capabilities.ResolveMaxOutput(req.Model, models.ModelCapabilities{})
}
if req.Temperature != nil {
antReq.Temperature = req.Temperature

View File

@@ -1,6 +1,7 @@
package providers
import (
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
"bufio"
"bytes"
"context"
@@ -190,25 +191,25 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
return nil, fmt.Errorf("decode models: %w", err)
}
models := make([]Model, 0, len(result.Data))
out := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
// Try known table first, then heuristic
caps, found := LookupKnownModel(m.ID)
caps, found := capabilities.LookupKnownModel(m.ID)
if !found {
caps = InferCapabilities(m.ID)
caps = capabilities.InferCapabilities(m.ID)
}
// Use context_length from API if available and we don't have it
if m.ContextLength > 0 && caps.MaxContext == 0 {
caps.MaxContext = m.ContextLength
}
models = append(models, Model{
out = append(out, Model{
ID: m.ID,
OwnedBy: m.OwnedBy,
Capabilities: caps,
})
}
return models, nil
return out, nil
}
// ── HTTP Layer ──────────────────────────────

View File

@@ -1,6 +1,8 @@
package providers
import (
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/models"
"context"
"encoding/json"
"fmt"
@@ -65,12 +67,12 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
return nil, fmt.Errorf("openrouter decode models: %w", err)
}
models := make([]Model, 0, len(result.Data))
out := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
// Start with known table, fall back to heuristic
caps, found := LookupKnownModel(m.ID)
caps, found := capabilities.LookupKnownModel(m.ID)
if !found {
caps = InferCapabilities(m.ID)
caps = capabilities.InferCapabilities(m.ID)
}
// Overlay context length from OpenRouter metadata
@@ -93,12 +95,12 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
caps.Streaming = true
// Parse pricing (OpenRouter uses per-token strings, convert to per-1M)
var pricing *ModelPricing
var pricing *models.ModelPricing
if m.Pricing.Prompt != "" {
inputPerToken, _ := strconv.ParseFloat(m.Pricing.Prompt, 64)
outputPerToken, _ := strconv.ParseFloat(m.Pricing.Completion, 64)
if inputPerToken > 0 || outputPerToken > 0 {
pricing = &ModelPricing{
pricing = &models.ModelPricing{
InputPerM: inputPerToken * 1_000_000,
OutputPerM: outputPerToken * 1_000_000,
}
@@ -116,7 +118,7 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
name = m.ID
}
models = append(models, Model{
out = append(out, Model{
ID: m.ID,
Name: name,
OwnedBy: ownedBy,
@@ -124,7 +126,7 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
Pricing: pricing,
})
}
return models, nil
return out, nil
}
// ── OpenRouter Wire Types ───────────────────

View File

@@ -3,6 +3,8 @@ package providers
import (
"context"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── Provider Interface ──────────────────────
@@ -26,12 +28,12 @@ type Provider interface {
// ── Configuration ───────────────────────────
// ProviderConfig holds credentials and endpoint for a configured provider.
// Populated from the api_configs table at call time.
// Populated from the provider_configs table at call time.
type ProviderConfig struct {
Endpoint string
APIKey string
CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer)
Settings map[string]interface{} // Provider-specific settings from provider_settings JSONB
Endpoint string
APIKey string
CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer)
Settings map[string]interface{} // Provider-specific settings from config JSONB
}
// ── Request / Response Types ────────────────
@@ -62,8 +64,8 @@ type FunctionCall struct {
// ToolDef describes a tool available to the LLM.
type ToolDef struct {
Type string `json:"type"` // "function"
Function FunctionDef `json:"function"`
Type string `json:"type"` // "function"
Function FunctionDef `json:"function"`
}
// FunctionDef is the schema for a tool function.
@@ -96,38 +98,19 @@ type CompletionResponse struct {
// StreamEvent is a single chunk from a streaming response.
type StreamEvent struct {
// Delta is the incremental text content (empty for non-content events).
Delta string `json:"delta,omitempty"`
// Done is true when the stream is complete.
Done bool `json:"done,omitempty"`
// FinishReason is set on the final event.
// "stop" = normal, "tool_calls" = LLM wants to call tools.
FinishReason string `json:"finish_reason,omitempty"`
// ToolCalls accumulates tool call data during streaming.
// Fully populated on the final event when FinishReason is "tool_calls".
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
// Model echoes back the model used.
Model string `json:"model,omitempty"`
// Error is set if the stream encountered an error.
Error error `json:"-"`
Delta string `json:"delta,omitempty"`
Done bool `json:"done,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Model string `json:"model,omitempty"`
Error error `json:"-"`
}
// Model represents an available model from a provider, with capabilities.
type Model struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
OwnedBy string `json:"owned_by,omitempty"`
Capabilities ModelCapabilities `json:"capabilities"`
Pricing *ModelPricing `json:"pricing,omitempty"`
}
// ModelPricing holds per-million-token costs.
type ModelPricing struct {
InputPerM float64 `json:"input_per_m,omitempty"`
OutputPerM float64 `json:"output_per_m,omitempty"`
ID string `json:"id"`
Name string `json:"name,omitempty"`
OwnedBy string `json:"owned_by,omitempty"`
Capabilities models.ModelCapabilities `json:"capabilities"`
Pricing *models.ModelPricing `json:"pricing,omitempty"`
}

View File

@@ -1,6 +1,8 @@
package providers
import (
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/models"
"context"
"encoding/json"
"fmt"
@@ -62,29 +64,30 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
return nil, fmt.Errorf("venice decode models: %w", err)
}
models := make([]Model, 0, len(result.Data))
out := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
spec := m.ModelSpec
vcaps := spec.Capabilities
caps := ModelCapabilities{
caps := models.ModelCapabilities{
Streaming: true,
ToolCalling: vcaps.SupportsFunctionCalling,
Vision: vcaps.SupportsVision,
Reasoning: vcaps.SupportsReasoning,
WebSearch: vcaps.SupportsWebSearch,
CodeOptimized: vcaps.OptimizedForCode,
MaxContext: spec.AvailableContextTokens,
}
// Venice doesn't report max output tokens directly.
// Try known table, then derive from context.
if known, ok := LookupKnownModel(m.ID); ok && known.MaxOutputTokens > 0 {
if known, ok := capabilities.LookupKnownModel(m.ID); ok && known.MaxOutputTokens > 0 {
caps.MaxOutputTokens = known.MaxOutputTokens
}
var pricing *ModelPricing
var pricing *models.ModelPricing
if spec.Pricing.Input.USD > 0 {
pricing = &ModelPricing{
pricing = &models.ModelPricing{
InputPerM: spec.Pricing.Input.USD,
OutputPerM: spec.Pricing.Output.USD,
}
@@ -95,7 +98,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
name = m.ID
}
models = append(models, Model{
out = append(out, Model{
ID: m.ID,
Name: name,
OwnedBy: "venice",
@@ -103,7 +106,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
Pricing: pricing,
})
}
return models, nil
return out, nil
}
// ── Venice Wire Types ───────────────────────
@@ -137,6 +140,7 @@ type veniceCapabilities struct {
SupportsResponseSchema bool `json:"supportsResponseSchema"`
SupportsAudioInput bool `json:"supportsAudioInput"`
SupportsLogProbs bool `json:"supportsLogProbs"`
OptimizedForCode bool `json:"optimizedForCode"`
}
type venicePricing struct {

296
server/store/interfaces.go Normal file
View File

@@ -0,0 +1,296 @@
package store
import (
"context"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// =========================================
// STORES — Data Access Layer
// =========================================
// Every database operation goes through these interfaces.
// Handlers never touch SQL directly. This makes the DB
// portable (Postgres today, SQLite/MySQL later) and
// handlers testable with in-memory implementations.
// =========================================
// Stores bundles all store interfaces for dependency injection.
type Stores struct {
Providers ProviderStore
Catalog CatalogStore
Personas PersonaStore
Policies PolicyStore
UserSettings UserModelSettingsStore
Users UserStore
Teams TeamStore
Channels ChannelStore
Messages MessageStore
Audit AuditStore
Notes NoteStore
GlobalConfig GlobalConfigStore
}
// =========================================
// PROVIDER STORE
// =========================================
type ProviderStore interface {
Create(ctx context.Context, cfg *models.ProviderConfig) error
GetByID(ctx context.Context, id string) (*models.ProviderConfig, error)
Update(ctx context.Context, id string, patch models.ProviderConfigPatch) error
Delete(ctx context.Context, id string) error
// Scoped queries
ListGlobal(ctx context.Context) ([]models.ProviderConfig, error)
ListForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error)
ListForUser(ctx context.Context, userID string) ([]models.ProviderConfig, error) // personal scope
ListAccessible(ctx context.Context, userID string) ([]models.ProviderConfig, error) // all user can access
// Access check
UserCanAccess(ctx context.Context, userID, configID string) (bool, error)
}
// =========================================
// CATALOG STORE
// =========================================
type CatalogStore interface {
// Sync from provider API
UpsertFromSync(ctx context.Context, providerConfigID string, entries []CatalogSyncEntry) (added, updated int, err error)
// Queries
GetByID(ctx context.Context, id string) (*models.CatalogEntry, error)
GetByModelID(ctx context.Context, providerConfigID, modelID string) (*models.CatalogEntry, error)
GetByModelIDAny(ctx context.Context, modelID string) (*models.CatalogEntry, error) // any provider, most recently synced
ListVisible(ctx context.Context) ([]models.CatalogEntry, error)
ListEnabledForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error)
ListForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error)
ListAll(ctx context.Context) ([]models.CatalogEntry, error) // admin view
// Visibility management
SetVisibility(ctx context.Context, id string, visibility string) error
BulkSetVisibility(ctx context.Context, providerConfigID string, visibility string) error
BulkSetVisibilityAll(ctx context.Context, visibility string) error
// Delete
Delete(ctx context.Context, id string) error
DeleteForProvider(ctx context.Context, providerConfigID string) error
}
// CatalogSyncEntry is the input format from provider FetchModels.
type CatalogSyncEntry struct {
ModelID string
DisplayName string
Capabilities models.ModelCapabilities
Pricing *models.ModelPricing
}
// =========================================
// PERSONA STORE
// =========================================
type PersonaStore interface {
Create(ctx context.Context, p *models.Persona) error
GetByID(ctx context.Context, id string) (*models.Persona, error)
Update(ctx context.Context, id string, patch models.PersonaPatch) error
Delete(ctx context.Context, id string) error
// Scoped queries
ListForUser(ctx context.Context, userID string) ([]models.Persona, error) // all visible to user
ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error)
ListGlobal(ctx context.Context) ([]models.Persona, error)
ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) // user's own
// Grants
SetGrants(ctx context.Context, personaID string, grants []models.Grant) error
GetGrants(ctx context.Context, personaID string) ([]models.Grant, error)
GetToolGrants(ctx context.Context, personaID string) ([]string, error)
// Access check
UserCanAccess(ctx context.Context, userID, personaID string) (bool, error)
}
// =========================================
// POLICY STORE
// =========================================
type PolicyStore interface {
Get(ctx context.Context, key string) (string, error)
GetBool(ctx context.Context, key string) (bool, error)
Set(ctx context.Context, key, value string, updatedBy string) error
GetAll(ctx context.Context) (map[string]string, error)
}
// =========================================
// USER MODEL SETTINGS STORE
// =========================================
type UserModelSettingsStore interface {
GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error)
GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error)
Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error
BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error
}
// =========================================
// USER STORE
// =========================================
type UserStore interface {
Create(ctx context.Context, u *models.User) error
GetByID(ctx context.Context, id string) (*models.User, error)
GetByUsername(ctx context.Context, username string) (*models.User, error)
GetByEmail(ctx context.Context, email string) (*models.User, error)
GetByLogin(ctx context.Context, login string) (*models.User, error) // username or email
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, opts ListOptions) ([]models.User, int, error)
UpdateLastLogin(ctx context.Context, id string) error
SetActive(ctx context.Context, id string, active bool) error
// Refresh tokens
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error)
RevokeRefreshToken(ctx context.Context, tokenHash string) error
RevokeAllRefreshTokens(ctx context.Context, userID string) error
CleanExpiredTokens(ctx context.Context) error
}
// =========================================
// TEAM STORE
// =========================================
type TeamStore interface {
Create(ctx context.Context, t *models.Team) error
GetByID(ctx context.Context, id string) (*models.Team, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
List(ctx context.Context) ([]models.Team, error)
// Members
AddMember(ctx context.Context, teamID, userID, role string) error
RemoveMember(ctx context.Context, teamID, userID string) error
UpdateMemberRole(ctx context.Context, teamID, userID, role string) error
ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error)
GetMember(ctx context.Context, teamID, userID string) (*models.TeamMember, error)
GetUserTeamIDs(ctx context.Context, userID string) ([]string, error)
IsTeamAdmin(ctx context.Context, teamID, userID string) (bool, error)
IsMember(ctx context.Context, teamID, userID string) (bool, error)
}
// =========================================
// CHANNEL STORE
// =========================================
type ChannelStore interface {
Create(ctx context.Context, ch *models.Channel) error
GetByID(ctx context.Context, id string) (*models.Channel, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
ListForUser(ctx context.Context, userID string, opts ListOptions) ([]models.Channel, int, error)
Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Channel, int, error)
// Cursor management (conversation forking)
GetCursor(ctx context.Context, channelID, userID string) (*models.ChannelCursor, error)
SetCursor(ctx context.Context, channelID, userID, leafID string) error
// Channel models
SetModel(ctx context.Context, cm *models.ChannelModel) error
GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error)
// Ownership check
UserOwns(ctx context.Context, channelID, userID string) (bool, error)
}
// =========================================
// MESSAGE STORE
// =========================================
type MessageStore interface {
Create(ctx context.Context, m *models.Message) error
GetByID(ctx context.Context, id string) (*models.Message, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error // soft delete
ListForChannel(ctx context.Context, channelID string, opts ListOptions) ([]models.Message, error)
// Tree operations
GetChildren(ctx context.Context, parentID string) ([]models.Message, error)
GetSiblings(ctx context.Context, messageID string) ([]models.Message, error)
GetPathToRoot(ctx context.Context, messageID string) ([]models.Message, error)
GetNextSiblingIndex(ctx context.Context, parentID string) (int, error)
// Count
CountForChannel(ctx context.Context, channelID string) (int, error)
}
// =========================================
// AUDIT STORE
// =========================================
type AuditStore interface {
Log(ctx context.Context, entry *models.AuditEntry) error
List(ctx context.Context, opts AuditListOptions) ([]models.AuditEntry, int, error)
}
type AuditListOptions struct {
ListOptions
ActorID string
Action string
ResourceType string
ResourceID string
Since *time.Time
Until *time.Time
}
// =========================================
// NOTE STORE
// =========================================
type NoteStore interface {
Create(ctx context.Context, n *models.Note) error
GetByID(ctx context.Context, id string) (*models.Note, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
ListForUser(ctx context.Context, userID string, opts NoteListOptions) ([]models.Note, int, error)
Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Note, int, error)
BulkDelete(ctx context.Context, ids []string, userID string) (int, error)
}
type NoteListOptions struct {
ListOptions
FolderPath string
Tag string
TeamID string
}
// =========================================
// GLOBAL CONFIG STORE
// =========================================
type GlobalConfigStore interface {
Get(ctx context.Context, key string) (models.JSONMap, error)
Set(ctx context.Context, key string, value models.JSONMap, updatedBy string) error
GetAll(ctx context.Context) (map[string]models.JSONMap, error)
}
// =========================================
// SHARED TYPES
// =========================================
// ListOptions provides standard pagination/sort for list queries.
type ListOptions struct {
Limit int
Offset int
Sort string // column name
Order string // "asc" or "desc"
}
// DefaultListOptions returns sensible defaults.
func DefaultListOptions() ListOptions {
return ListOptions{
Limit: 50,
Order: "desc",
}
}

View File

@@ -0,0 +1,82 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type AuditStore struct{}
func NewAuditStore() *AuditStore { return &AuditStore{} }
func (s *AuditStore) Log(ctx context.Context, entry *models.AuditEntry) error {
metadataJSON := ToJSON(entry.Metadata)
return DB.QueryRowContext(ctx, `
INSERT INTO audit_log (actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at`,
models.NullString(entry.ActorID), entry.Action, entry.ResourceType,
entry.ResourceID, metadataJSON, entry.IPAddress, entry.UserAgent,
).Scan(&entry.ID, &entry.CreatedAt)
}
func (s *AuditStore) List(ctx context.Context, opts store.AuditListOptions) ([]models.AuditEntry, int, error) {
b := NewSelect("id, actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent, created_at", "audit_log")
if opts.ActorID != "" {
b.Where("actor_id = ?", opts.ActorID)
}
if opts.Action != "" {
b.Where("action = ?", opts.Action)
}
if opts.ResourceType != "" {
b.Where("resource_type = ?", opts.ResourceType)
}
if opts.ResourceID != "" {
b.Where("resource_id = ?", opts.ResourceID)
}
if opts.Since != nil {
b.Where("created_at >= ?", *opts.Since)
}
if opts.Until != nil {
b.Where("created_at <= ?", *opts.Until)
}
// Count
countQ, countArgs := b.CountBuild()
var total int
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
// Results
if opts.Sort == "" {
b.OrderBy("created_at", "DESC")
}
b.Paginate(opts.ListOptions)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var result []models.AuditEntry
for rows.Next() {
var e models.AuditEntry
var actorID sql.NullString
var metadataJSON []byte
err := rows.Scan(&e.ID, &actorID, &e.Action, &e.ResourceType, &e.ResourceID,
&metadataJSON, &e.IPAddress, &e.UserAgent, &e.CreatedAt)
if err != nil {
return nil, 0, err
}
e.ActorID = NullableStringPtr(actorID)
json.Unmarshal(metadataJSON, &e.Metadata)
result = append(result, e)
}
return result, total, rows.Err()
}

View File

@@ -0,0 +1,224 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type CatalogStore struct{}
func NewCatalogStore() *CatalogStore { return &CatalogStore{} }
const catalogCols = `id, provider_config_id, model_id, display_name,
capabilities, pricing, visibility, last_synced_at, created_at, updated_at`
// catalogColsMC is catalogCols with mc. prefix for use in JOINs
// where id/created_at/updated_at are ambiguous.
const catalogColsMC = `mc.id, mc.provider_config_id, mc.model_id, mc.display_name,
mc.capabilities, mc.pricing, mc.visibility, mc.last_synced_at, mc.created_at, mc.updated_at`
// UpsertFromSync bulk-inserts or updates catalog entries from a provider API fetch.
// New models default to 'disabled' visibility (secure by default).
func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID string, entries []store.CatalogSyncEntry) (added, updated int, err error) {
now := time.Now()
for _, e := range entries {
capsJSON := ToJSON(e.Capabilities)
var pricingJSON []byte
if e.Pricing != nil {
pricingJSON = ToJSON(e.Pricing)
}
var existingID string
err := DB.QueryRowContext(ctx,
"SELECT id FROM model_catalog WHERE provider_config_id = $1 AND model_id = $2",
providerConfigID, e.ModelID,
).Scan(&existingID)
if err == sql.ErrNoRows {
// Insert new (disabled by default)
_, err = DB.ExecContext(ctx, `
INSERT INTO model_catalog (provider_config_id, model_id, display_name,
capabilities, pricing, visibility, last_synced_at)
VALUES ($1, $2, $3, $4, $5, 'disabled', $6)`,
providerConfigID, e.ModelID, e.DisplayName, capsJSON, pricingJSON, now)
if err != nil {
return added, updated, fmt.Errorf("insert %s: %w", e.ModelID, err)
}
added++
} else if err == nil {
// Update existing (preserve visibility)
_, err = DB.ExecContext(ctx, `
UPDATE model_catalog SET display_name = $1, capabilities = $2,
pricing = $3, last_synced_at = $4
WHERE id = $5`,
e.DisplayName, capsJSON, pricingJSON, now, existingID)
if err != nil {
return added, updated, fmt.Errorf("update %s: %w", e.ModelID, err)
}
updated++
} else {
return added, updated, fmt.Errorf("check %s: %w", e.ModelID, err)
}
}
return added, updated, nil
}
func (s *CatalogStore) GetByID(ctx context.Context, id string) (*models.CatalogEntry, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE id = $1", catalogCols), id)
return scanCatalogEntry(row)
}
func (s *CatalogStore) GetByModelID(ctx context.Context, providerConfigID, modelID string) (*models.CatalogEntry, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = $1 AND model_id = $2", catalogCols),
providerConfigID, modelID)
return scanCatalogEntry(row)
}
// GetByModelIDAny returns the most recently synced catalog entry for a model_id
// across any provider. Used to resolve capabilities for presets with auto-resolve
// (no specific provider_config_id).
func (s *CatalogStore) GetByModelIDAny(ctx context.Context, modelID string) (*models.CatalogEntry, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE model_id = $1 ORDER BY last_synced_at DESC NULLS LAST LIMIT 1", catalogCols),
modelID)
return scanCatalogEntry(row)
}
func (s *CatalogStore) ListVisible(ctx context.Context) ([]models.CatalogEntry, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf(`SELECT %s FROM model_catalog mc
JOIN provider_configs pc ON pc.id = mc.provider_config_id
WHERE mc.visibility = 'enabled' AND pc.scope = 'global' AND pc.is_active = true
ORDER BY mc.model_id`, catalogColsMC))
if err != nil {
return nil, err
}
defer rows.Close()
return scanCatalogEntries(rows)
}
func (s *CatalogStore) ListForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = $1 ORDER BY model_id", catalogCols),
providerConfigID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCatalogEntries(rows)
}
func (s *CatalogStore) ListEnabledForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = $1 AND visibility = 'enabled' ORDER BY model_id", catalogCols),
providerConfigID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCatalogEntries(rows)
}
func (s *CatalogStore) ListAll(ctx context.Context) ([]models.CatalogEntry, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog ORDER BY model_id", catalogCols))
if err != nil {
return nil, err
}
defer rows.Close()
return scanCatalogEntries(rows)
}
func (s *CatalogStore) SetVisibility(ctx context.Context, id string, visibility string) error {
_, err := DB.ExecContext(ctx,
"UPDATE model_catalog SET visibility = $1 WHERE id = $2", visibility, id)
return err
}
func (s *CatalogStore) BulkSetVisibility(ctx context.Context, providerConfigID string, visibility string) error {
_, err := DB.ExecContext(ctx,
"UPDATE model_catalog SET visibility = $1 WHERE provider_config_id = $2",
visibility, providerConfigID)
return err
}
func (s *CatalogStore) BulkSetVisibilityAll(ctx context.Context, visibility string) error {
_, err := DB.ExecContext(ctx,
"UPDATE model_catalog SET visibility = $1", visibility)
return err
}
func (s *CatalogStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM model_catalog WHERE id = $1", id)
return err
}
func (s *CatalogStore) DeleteForProvider(ctx context.Context, providerConfigID string) error {
_, err := DB.ExecContext(ctx,
"DELETE FROM model_catalog WHERE provider_config_id = $1", providerConfigID)
return err
}
// ── Scanners ────────────────────────────────
func scanCatalogEntry(row *sql.Row) (*models.CatalogEntry, error) {
var e models.CatalogEntry
var capsJSON, pricingJSON []byte
var displayName sql.NullString
var lastSynced sql.NullTime
err := row.Scan(
&e.ID, &e.ProviderConfigID, &e.ModelID, &displayName,
&capsJSON, &pricingJSON, &e.Visibility, &lastSynced,
&e.CreatedAt, &e.UpdatedAt,
)
if err != nil {
return nil, err
}
e.DisplayName = NullableString(displayName)
json.Unmarshal(capsJSON, &e.Capabilities)
if len(pricingJSON) > 0 {
e.Pricing = &models.ModelPricing{}
json.Unmarshal(pricingJSON, e.Pricing)
}
if lastSynced.Valid {
e.LastSyncedAt = &lastSynced.Time
}
return &e, nil
}
func scanCatalogEntries(rows *sql.Rows) ([]models.CatalogEntry, error) {
result := make([]models.CatalogEntry, 0) // never nil — serializes as [] not null
for rows.Next() {
var e models.CatalogEntry
var capsJSON, pricingJSON []byte
var displayName sql.NullString
var lastSynced sql.NullTime
err := rows.Scan(
&e.ID, &e.ProviderConfigID, &e.ModelID, &displayName,
&capsJSON, &pricingJSON, &e.Visibility, &lastSynced,
&e.CreatedAt, &e.UpdatedAt,
)
if err != nil {
return nil, err
}
e.DisplayName = NullableString(displayName)
json.Unmarshal(capsJSON, &e.Capabilities)
if len(pricingJSON) > 0 {
e.Pricing = &models.ModelPricing{}
json.Unmarshal(pricingJSON, e.Pricing)
}
if lastSynced.Valid {
e.LastSyncedAt = &lastSynced.Time
}
result = append(result, e)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,221 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type ChannelStore struct{}
func NewChannelStore() *ChannelStore { return &ChannelStore{} }
func (s *ChannelStore) Create(ctx context.Context, ch *models.Channel) error {
return DB.QueryRowContext(ctx, `
INSERT INTO channels (user_id, title, description, type, model, system_prompt,
provider_config_id, is_archived, is_pinned, folder_id, team_id, settings)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING id, created_at, updated_at`,
ch.UserID, ch.Title, ch.Description, ch.Type, ch.Model, ch.SystemPrompt,
models.NullString(ch.ProviderConfigID), ch.IsArchived, ch.IsPinned,
models.NullString(ch.FolderID), models.NullString(ch.TeamID), ToJSON(ch.Settings),
).Scan(&ch.ID, &ch.CreatedAt, &ch.UpdatedAt)
}
func (s *ChannelStore) GetByID(ctx context.Context, id string) (*models.Channel, error) {
var ch models.Channel
var providerConfigID, folderID, teamID sql.NullString
var desc sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, user_id, title, description, type, model, system_prompt,
provider_config_id, is_archived, is_pinned, folder_id, team_id, settings,
created_at, updated_at
FROM channels WHERE id = $1`, id).Scan(
&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, &ch.SystemPrompt,
&providerConfigID, &ch.IsArchived, &ch.IsPinned, &folderID, &teamID, &settingsJSON,
&ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
return nil, err
}
ch.Description = NullableString(desc)
ch.ProviderConfigID = NullableStringPtr(providerConfigID)
ch.FolderID = NullableStringPtr(folderID)
ch.TeamID = NullableStringPtr(teamID)
json.Unmarshal(settingsJSON, &ch.Settings)
return &ch, nil
}
func (s *ChannelStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("channels")
for k, v := range fields {
if k == "settings" || k == "tags" {
b.SetJSON(k, v)
} else {
b.Set(k, v)
}
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *ChannelStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM channels WHERE id = $1", id)
return err
}
func (s *ChannelStore) ListForUser(ctx context.Context, userID string, opts store.ListOptions) ([]models.Channel, int, error) {
// Count
var total int
DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels WHERE user_id = $1", userID).Scan(&total)
b := NewSelect(
"id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at",
"channels",
).Where("user_id = ?", userID)
if opts.Sort == "" {
b.OrderBy("updated_at", "DESC")
}
b.Paginate(opts)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var result []models.Channel
for rows.Next() {
var ch models.Channel
var providerConfigID, folderID, teamID, desc sql.NullString
var settingsJSON []byte
err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model,
&ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned,
&folderID, &teamID, &settingsJSON, &ch.CreatedAt, &ch.UpdatedAt)
if err != nil {
return nil, 0, err
}
ch.Description = NullableString(desc)
ch.ProviderConfigID = NullableStringPtr(providerConfigID)
ch.FolderID = NullableStringPtr(folderID)
ch.TeamID = NullableStringPtr(teamID)
json.Unmarshal(settingsJSON, &ch.Settings)
result = append(result, ch)
}
return result, total, rows.Err()
}
func (s *ChannelStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Channel, int, error) {
// Simple title search for now — will add full-text when search feature lands
b := NewSelect(
"id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at",
"channels",
).Where("user_id = ?", userID).Where("title ILIKE ?", "%"+query+"%")
b.OrderBy("updated_at", "DESC")
b.Paginate(opts)
var total int
DB.QueryRowContext(ctx,
"SELECT COUNT(*) FROM channels WHERE user_id = $1 AND title ILIKE $2",
userID, "%"+query+"%").Scan(&total)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var result []models.Channel
for rows.Next() {
var ch models.Channel
var providerConfigID, folderID, teamID, desc sql.NullString
var settingsJSON []byte
err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model,
&ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned,
&folderID, &teamID, &settingsJSON, &ch.CreatedAt, &ch.UpdatedAt)
if err != nil {
return nil, 0, err
}
ch.Description = NullableString(desc)
ch.ProviderConfigID = NullableStringPtr(providerConfigID)
ch.FolderID = NullableStringPtr(folderID)
ch.TeamID = NullableStringPtr(teamID)
json.Unmarshal(settingsJSON, &ch.Settings)
result = append(result, ch)
}
return result, total, rows.Err()
}
func (s *ChannelStore) GetCursor(ctx context.Context, channelID, userID string) (*models.ChannelCursor, error) {
var c models.ChannelCursor
var leafID sql.NullString
err := DB.QueryRowContext(ctx, `
SELECT id, channel_id, user_id, active_leaf_id, updated_at
FROM channel_cursors WHERE channel_id = $1 AND user_id = $2`,
channelID, userID).Scan(&c.ID, &c.ChannelID, &c.UserID, &leafID, &c.UpdatedAt)
if err != nil {
return nil, err
}
c.ActiveLeafID = NullableStringPtr(leafID)
return &c, nil
}
func (s *ChannelStore) SetCursor(ctx context.Context, channelID, userID, leafID string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
VALUES ($1, $2, $3)
ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = $3, updated_at = NOW()`,
channelID, userID, leafID)
return err
}
func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO channel_models (channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (channel_id, model_id) DO UPDATE SET
provider_config_id = $3, display_name = $4, system_prompt = $5, settings = $6, is_default = $7`,
cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
return err
}
func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, model_id, COALESCE(provider_config_id::text, ''),
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
FROM channel_models WHERE channel_id = $1`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.ChannelModel
for rows.Next() {
var cm models.ChannelModel
if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
return nil, err
}
result = append(result, cm)
}
return result, rows.Err()
}
func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM channels WHERE id = $1 AND user_id = $2)",
channelID, userID).Scan(&exists)
return exists, err
}

View File

@@ -0,0 +1,59 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type GlobalConfigStore struct{}
func NewGlobalConfigStore() *GlobalConfigStore { return &GlobalConfigStore{} }
func (s *GlobalConfigStore) Get(ctx context.Context, key string) (models.JSONMap, error) {
var valueJSON []byte
err := DB.QueryRowContext(ctx,
"SELECT value FROM global_settings WHERE key = $1", key).Scan(&valueJSON)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
var result models.JSONMap
json.Unmarshal(valueJSON, &result)
return result, nil
}
func (s *GlobalConfigStore) Set(ctx context.Context, key string, value models.JSONMap, updatedBy string) error {
valueJSON := ToJSON(value)
_, err := DB.ExecContext(ctx, `
INSERT INTO global_settings (key, value, updated_by, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_by = $3, updated_at = NOW()`,
key, valueJSON, updatedBy)
return err
}
func (s *GlobalConfigStore) GetAll(ctx context.Context) (map[string]models.JSONMap, error) {
rows, err := DB.QueryContext(ctx, "SELECT key, value FROM global_settings")
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]models.JSONMap)
for rows.Next() {
var key string
var valueJSON []byte
if err := rows.Scan(&key, &valueJSON); err != nil {
continue
}
var m models.JSONMap
json.Unmarshal(valueJSON, &m)
result[key] = m
}
return result, rows.Err()
}

View File

@@ -0,0 +1,253 @@
package postgres
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// DB is the shared database connection pool.
// Set during initialization via SetDB.
var DB *sql.DB
// SetDB configures the shared database connection for all stores.
func SetDB(db *sql.DB) {
DB = db
}
// ── Dynamic SQL Builder ─────────────────────
// Replaces the copy-pasted addClause/addField pattern
// found in admin.go, presets.go, team_providers.go, apiconfigs.go.
// UpdateBuilder constructs a dynamic UPDATE statement.
type UpdateBuilder struct {
table string
sets []string
args []interface{}
where []string
argIdx int
}
// NewUpdate creates an UpdateBuilder for the given table.
func NewUpdate(table string) *UpdateBuilder {
return &UpdateBuilder{table: table}
}
// Set adds a column=value pair to the UPDATE.
func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder {
b.argIdx++
b.sets = append(b.sets, fmt.Sprintf("%s = $%d", col, b.argIdx))
b.args = append(b.args, val)
return b
}
// SetJSON adds a JSONB column from a map.
func (b *UpdateBuilder) SetJSON(col string, val interface{}) *UpdateBuilder {
data, err := json.Marshal(val)
if err != nil {
data = []byte("{}")
}
return b.Set(col, string(data))
}
// SetIf conditionally adds a column if the pointer is non-nil.
func (b *UpdateBuilder) SetIf(col string, val interface{}, set bool) *UpdateBuilder {
if set {
return b.Set(col, val)
}
return b
}
// Where adds a WHERE condition.
func (b *UpdateBuilder) Where(col string, val interface{}) *UpdateBuilder {
b.argIdx++
b.where = append(b.where, fmt.Sprintf("%s = $%d", col, b.argIdx))
b.args = append(b.args, val)
return b
}
// HasSets returns true if any SET clauses were added.
func (b *UpdateBuilder) HasSets() bool {
return len(b.sets) > 0
}
// Build returns the SQL string and args.
func (b *UpdateBuilder) Build() (string, []interface{}) {
sql := fmt.Sprintf("UPDATE %s SET %s", b.table, strings.Join(b.sets, ", "))
if len(b.where) > 0 {
sql += " WHERE " + strings.Join(b.where, " AND ")
}
return sql, b.args
}
// Exec executes the built UPDATE.
func (b *UpdateBuilder) Exec(db *sql.DB) (sql.Result, error) {
q, args := b.Build()
return db.Exec(q, args...)
}
// ── Query Builder ───────────────────────────
// SelectBuilder constructs a dynamic SELECT statement.
type SelectBuilder struct {
cols string
table string
joins []string
where []string
args []interface{}
orderBy string
limit int
offset int
argIdx int
}
// NewSelect creates a SelectBuilder.
func NewSelect(cols, table string) *SelectBuilder {
return &SelectBuilder{cols: cols, table: table}
}
// Join adds a JOIN clause.
func (b *SelectBuilder) Join(join string) *SelectBuilder {
b.joins = append(b.joins, join)
return b
}
// Where adds a WHERE condition with a parameter.
func (b *SelectBuilder) Where(clause string, args ...interface{}) *SelectBuilder {
for _, arg := range args {
b.argIdx++
clause = strings.Replace(clause, "?", fmt.Sprintf("$%d", b.argIdx), 1)
b.args = append(b.args, arg)
}
b.where = append(b.where, clause)
return b
}
// WhereRaw adds a WHERE condition without parameters.
func (b *SelectBuilder) WhereRaw(clause string) *SelectBuilder {
b.where = append(b.where, clause)
return b
}
// OrderBy sets the ORDER BY clause.
func (b *SelectBuilder) OrderBy(col, order string) *SelectBuilder {
if order == "" {
order = "DESC"
}
b.orderBy = fmt.Sprintf("%s %s", col, strings.ToUpper(order))
return b
}
// Paginate sets LIMIT and OFFSET from ListOptions.
func (b *SelectBuilder) Paginate(opts store.ListOptions) *SelectBuilder {
if opts.Limit > 0 {
b.limit = opts.Limit
}
if opts.Offset > 0 {
b.offset = opts.Offset
}
if opts.Sort != "" {
b.OrderBy(opts.Sort, opts.Order)
}
return b
}
// Build returns the SQL string and args.
func (b *SelectBuilder) Build() (string, []interface{}) {
q := fmt.Sprintf("SELECT %s FROM %s", b.cols, b.table)
for _, j := range b.joins {
q += " " + j
}
if len(b.where) > 0 {
q += " WHERE " + strings.Join(b.where, " AND ")
}
if b.orderBy != "" {
q += " ORDER BY " + b.orderBy
}
if b.limit > 0 {
q += fmt.Sprintf(" LIMIT %d", b.limit)
}
if b.offset > 0 {
q += fmt.Sprintf(" OFFSET %d", b.offset)
}
return q, b.args
}
// CountBuild returns a SELECT COUNT(*) version of the query (no order/limit).
func (b *SelectBuilder) CountBuild() (string, []interface{}) {
q := fmt.Sprintf("SELECT COUNT(*) FROM %s", b.table)
for _, j := range b.joins {
q += " " + j
}
if len(b.where) > 0 {
q += " WHERE " + strings.Join(b.where, " AND ")
}
return q, b.args
}
// ── JSONB Helpers ───────────────────────────
// ToJSON marshals a value to JSON bytes for JSONB columns.
func ToJSON(v interface{}) []byte {
if v == nil {
return []byte("{}")
}
b, err := json.Marshal(v)
if err != nil {
return []byte("{}")
}
return b
}
// ScanJSON scans a JSONB column into a target.
func ScanJSON(src interface{}, dst interface{}) error {
if src == nil {
return nil
}
var data []byte
switch v := src.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return fmt.Errorf("unsupported JSONB type: %T", src)
}
return json.Unmarshal(data, dst)
}
// NullableString returns the string value or empty string from sql.NullString.
func NullableString(ns sql.NullString) string {
if ns.Valid {
return ns.String
}
return ""
}
// NullableStringPtr returns a *string from sql.NullString (nil if not valid).
func NullableStringPtr(ns sql.NullString) *string {
if ns.Valid {
return &ns.String
}
return nil
}
// NullableFloat64Ptr returns a *float64 from sql.NullFloat64.
func NullableFloat64Ptr(nf sql.NullFloat64) *float64 {
if nf.Valid {
return &nf.Float64
}
return nil
}
// NullableIntPtr returns an *int from sql.NullInt64.
func NullableIntPtr(ni sql.NullInt64) *int {
if ni.Valid {
v := int(ni.Int64)
return &v
}
return nil
}

View File

@@ -0,0 +1,191 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type MessageStore struct{}
func NewMessageStore() *MessageStore { return &MessageStore{} }
func (s *MessageStore) Create(ctx context.Context, m *models.Message) error {
toolCallsJSON := ToJSON(m.ToolCalls)
metadataJSON := ToJSON(m.Metadata)
return DB.QueryRowContext(ctx, `
INSERT INTO messages (channel_id, role, content, model, tokens_used, tool_calls,
metadata, parent_id, sibling_index, participant_type, participant_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING id, created_at`,
m.ChannelID, m.Role, m.Content, m.Model, m.TokensUsed,
toolCallsJSON, metadataJSON,
models.NullString(m.ParentID), m.SiblingIndex,
m.ParticipantType, m.ParticipantID,
).Scan(&m.ID, &m.CreatedAt)
}
func (s *MessageStore) GetByID(ctx context.Context, id string) (*models.Message, error) {
var m models.Message
var parentID sql.NullString
var toolCallsJSON, metadataJSON []byte
var deletedAt sql.NullTime
err := DB.QueryRowContext(ctx, `
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
FROM messages WHERE id = $1`, id).Scan(
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
&toolCallsJSON, &metadataJSON,
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
&deletedAt, &m.CreatedAt,
)
if err != nil {
return nil, err
}
m.ParentID = NullableStringPtr(parentID)
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
json.Unmarshal(metadataJSON, &m.Metadata)
if deletedAt.Valid {
m.DeletedAt = &deletedAt.Time
}
return &m, nil
}
func (s *MessageStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("messages")
for k, v := range fields {
if k == "tool_calls" || k == "metadata" {
b.SetJSON(k, v)
} else {
b.Set(k, v)
}
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *MessageStore) Delete(ctx context.Context, id string) error {
now := time.Now()
_, err := DB.ExecContext(ctx, "UPDATE messages SET deleted_at = $1 WHERE id = $2", now, id)
return err
}
func (s *MessageStore) ListForChannel(ctx context.Context, channelID string, opts store.ListOptions) ([]models.Message, error) {
b := NewSelect(
"id, channel_id, role, content, model, tokens_used, tool_calls, metadata, parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at",
"messages",
).Where("channel_id = ?", channelID).WhereRaw("deleted_at IS NULL")
if opts.Sort == "" {
b.OrderBy("created_at", "ASC")
}
b.Paginate(opts)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMessages(rows)
}
func (s *MessageStore) GetChildren(ctx context.Context, parentID string) ([]models.Message, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
FROM messages WHERE parent_id = $1 AND deleted_at IS NULL
ORDER BY sibling_index`, parentID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMessages(rows)
}
func (s *MessageStore) GetSiblings(ctx context.Context, messageID string) ([]models.Message, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
FROM messages
WHERE parent_id = (SELECT parent_id FROM messages WHERE id = $1)
AND deleted_at IS NULL
ORDER BY sibling_index`, messageID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMessages(rows)
}
func (s *MessageStore) GetPathToRoot(ctx context.Context, messageID string) ([]models.Message, error) {
rows, err := DB.QueryContext(ctx, `
WITH RECURSIVE path AS (
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
FROM messages WHERE id = $1
UNION ALL
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata,
m.parent_id, m.sibling_index, m.participant_type, m.participant_id, m.deleted_at, m.created_at
FROM messages m JOIN path p ON m.id = p.parent_id
)
SELECT * FROM path ORDER BY created_at ASC`, messageID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMessages(rows)
}
func (s *MessageStore) GetNextSiblingIndex(ctx context.Context, parentID string) (int, error) {
var maxIdx sql.NullInt64
err := DB.QueryRowContext(ctx,
"SELECT MAX(sibling_index) FROM messages WHERE parent_id = $1 AND deleted_at IS NULL",
parentID).Scan(&maxIdx)
if err != nil || !maxIdx.Valid {
return 0, err
}
return int(maxIdx.Int64) + 1, nil
}
func (s *MessageStore) CountForChannel(ctx context.Context, channelID string) (int, error) {
var count int
err := DB.QueryRowContext(ctx,
"SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL",
channelID).Scan(&count)
return count, err
}
func scanMessages(rows *sql.Rows) ([]models.Message, error) {
var result []models.Message
for rows.Next() {
var m models.Message
var parentID sql.NullString
var toolCallsJSON, metadataJSON []byte
var deletedAt sql.NullTime
err := rows.Scan(
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
&toolCallsJSON, &metadataJSON,
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
&deletedAt, &m.CreatedAt,
)
if err != nil {
return nil, err
}
m.ParentID = NullableStringPtr(parentID)
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
json.Unmarshal(metadataJSON, &m.Metadata)
if deletedAt.Valid {
m.DeletedAt = &deletedAt.Time
}
result = append(result, m)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,191 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"github.com/lib/pq"
)
type NoteStore struct{}
func NewNoteStore() *NoteStore { return &NoteStore{} }
func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
return DB.QueryRowContext(ctx, `
INSERT INTO notes (user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING id, created_at, updated_at`,
n.UserID, n.Title, n.Content, n.FolderPath,
pq.Array(n.Tags), ToJSON(n.Metadata),
models.NullString(n.SourceChannelID), models.NullString(n.TeamID),
).Scan(&n.ID, &n.CreatedAt, &n.UpdatedAt)
}
func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) {
var n models.Note
var sourceChannelID, teamID sql.NullString
var metadataJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, user_id, title, content, folder_path, tags, metadata,
source_channel_id, team_id, created_at, updated_at
FROM notes WHERE id = $1`, id).Scan(
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
pq.Array(&n.Tags), &metadataJSON,
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt,
)
if err != nil {
return nil, err
}
n.SourceChannelID = NullableStringPtr(sourceChannelID)
n.TeamID = NullableStringPtr(teamID)
json.Unmarshal(metadataJSON, &n.Metadata)
return &n, nil
}
func (s *NoteStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("notes")
for k, v := range fields {
if k == "metadata" {
b.SetJSON(k, v)
} else if k == "tags" {
if tags, ok := v.([]string); ok {
b.Set(k, pq.Array(tags))
}
} else {
b.Set(k, v)
}
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *NoteStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM notes WHERE id = $1", id)
return err
}
func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.NoteListOptions) ([]models.Note, int, error) {
b := NewSelect(
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
"notes",
).Where("user_id = ?", userID)
if opts.FolderPath != "" {
b.Where("folder_path = ?", opts.FolderPath)
}
if opts.Tag != "" {
b.Where("? = ANY(tags)", opts.Tag)
}
if opts.TeamID != "" {
b.Where("team_id = ?", opts.TeamID)
}
// Count
countQ, countArgs := b.CountBuild()
var total int
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
if opts.Sort == "" {
b.OrderBy("updated_at", "DESC")
}
b.Paginate(opts.ListOptions)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var result []models.Note
for rows.Next() {
var n models.Note
var sourceChannelID, teamID sql.NullString
var metadataJSON []byte
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
pq.Array(&n.Tags), &metadataJSON,
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt)
if err != nil {
return nil, 0, err
}
n.SourceChannelID = NullableStringPtr(sourceChannelID)
n.TeamID = NullableStringPtr(teamID)
json.Unmarshal(metadataJSON, &n.Metadata)
result = append(result, n)
}
return result, total, rows.Err()
}
func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Note, int, error) {
tsQuery := strings.Join(strings.Fields(query), " & ")
b := NewSelect(
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
"notes",
).Where("user_id = ?", userID).Where("search_vector @@ to_tsquery('english', ?)", tsQuery)
b.OrderBy("ts_rank(search_vector, to_tsquery('english', '"+tsQuery+"'))", "DESC")
b.Paginate(opts)
var total int
DB.QueryRowContext(ctx,
"SELECT COUNT(*) FROM notes WHERE user_id = $1 AND search_vector @@ to_tsquery('english', $2)",
userID, tsQuery).Scan(&total)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var result []models.Note
for rows.Next() {
var n models.Note
var sourceChannelID, teamID sql.NullString
var metadataJSON []byte
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
pq.Array(&n.Tags), &metadataJSON,
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt)
if err != nil {
return nil, 0, err
}
n.SourceChannelID = NullableStringPtr(sourceChannelID)
n.TeamID = NullableStringPtr(teamID)
json.Unmarshal(metadataJSON, &n.Metadata)
result = append(result, n)
}
return result, total, rows.Err()
}
func (s *NoteStore) BulkDelete(ctx context.Context, ids []string, userID string) (int, error) {
if len(ids) == 0 {
return 0, nil
}
placeholders := make([]string, len(ids))
args := make([]interface{}, 0, len(ids)+1)
args = append(args, userID)
for i, id := range ids {
placeholders[i] = fmt.Sprintf("$%d", i+2)
args = append(args, id)
}
result, err := DB.ExecContext(ctx,
fmt.Sprintf("DELETE FROM notes WHERE user_id = $1 AND id IN (%s)",
strings.Join(placeholders, ",")),
args...)
if err != nil {
return 0, err
}
n, _ := result.RowsAffected()
return int(n), nil
}

View File

@@ -0,0 +1,295 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type PersonaStore struct{}
func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
const personaCols = `id, name, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared, created_at, updated_at`
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
return DB.QueryRowContext(ctx, `
INSERT INTO personas (name, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
RETURNING id, created_at, updated_at`,
p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID,
models.NullString(p.ProviderConfigID),
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
p.Scope, models.NullString(p.OwnerID), p.CreatedBy, p.IsActive, p.IsShared,
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
}
func (s *PersonaStore) GetByID(ctx context.Context, id string) (*models.Persona, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE id = $1", personaCols), id)
p, err := scanPersona(row)
if err != nil {
return nil, err
}
// Load grants
grants, _ := s.GetGrants(ctx, id)
p.Grants = grants
return p, nil
}
func (s *PersonaStore) Update(ctx context.Context, id string, patch models.PersonaPatch) error {
b := NewUpdate("personas")
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.Description != nil {
b.Set("description", *patch.Description)
}
if patch.Icon != nil {
b.Set("icon", *patch.Icon)
}
if patch.Avatar != nil {
b.Set("avatar", *patch.Avatar)
}
if patch.BaseModelID != nil {
b.Set("base_model_id", *patch.BaseModelID)
}
if patch.ProviderConfigID != nil {
b.Set("provider_config_id", models.NullString(patch.ProviderConfigID))
}
if patch.SystemPrompt != nil {
b.Set("system_prompt", *patch.SystemPrompt)
}
if patch.Temperature != nil {
b.Set("temperature", models.NullFloat(patch.Temperature))
}
if patch.MaxTokens != nil {
b.Set("max_tokens", models.NullInt(patch.MaxTokens))
}
if patch.ThinkingBudget != nil {
b.Set("thinking_budget", models.NullInt(patch.ThinkingBudget))
}
if patch.TopP != nil {
b.Set("top_p", models.NullFloat(patch.TopP))
}
if patch.IsActive != nil {
b.Set("is_active", *patch.IsActive)
}
if patch.IsShared != nil {
b.Set("is_shared", *patch.IsShared)
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *PersonaStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM personas WHERE id = $1", id)
return err
}
// ListForUser returns all Personas visible to a user:
// global active + team-scoped (for user's teams) + personal + shared.
func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf(`SELECT %s FROM personas WHERE is_active = true AND (
scope = 'global'
OR (scope = 'personal' AND created_by = $1)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
OR (scope = 'personal' AND is_shared = true)
) ORDER BY scope, name`, personaCols), userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
func (s *PersonaStore) ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'team' AND owner_id = $1 AND is_active = true ORDER BY name", personaCols),
teamID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
func (s *PersonaStore) ListGlobal(ctx context.Context) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'global' ORDER BY name", personaCols))
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
func (s *PersonaStore) ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'personal' AND created_by = $1 ORDER BY name", personaCols),
userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
// ── Grants ──────────────────────────────────
// SetGrants replaces all grants for a Persona (delete + re-insert).
func (s *PersonaStore) SetGrants(ctx context.Context, personaID string, grants []models.Grant) error {
tx, err := DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, "DELETE FROM persona_grants WHERE persona_id = $1", personaID)
if err != nil {
return err
}
for _, g := range grants {
configJSON := ToJSON(g.Config)
_, err = tx.ExecContext(ctx, `
INSERT INTO persona_grants (persona_id, grant_type, grant_ref, config)
VALUES ($1, $2, $3, $4)`,
personaID, g.GrantType, g.GrantRef, configJSON)
if err != nil {
return fmt.Errorf("grant %s/%s: %w", g.GrantType, g.GrantRef, err)
}
}
return tx.Commit()
}
func (s *PersonaStore) GetGrants(ctx context.Context, personaID string) ([]models.Grant, error) {
rows, err := DB.QueryContext(ctx,
"SELECT id, persona_id, grant_type, grant_ref, config, created_at FROM persona_grants WHERE persona_id = $1 ORDER BY grant_type, grant_ref",
personaID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Grant
for rows.Next() {
var g models.Grant
var configJSON []byte
err := rows.Scan(&g.ID, &g.PersonaID, &g.GrantType, &g.GrantRef, &configJSON, &g.CreatedAt)
if err != nil {
return nil, err
}
json.Unmarshal(configJSON, &g.Config)
result = append(result, g)
}
return result, rows.Err()
}
// GetToolGrants returns just the tool names for a Persona.
func (s *PersonaStore) GetToolGrants(ctx context.Context, personaID string) ([]string, error) {
rows, err := DB.QueryContext(ctx,
"SELECT grant_ref FROM persona_grants WHERE persona_id = $1 AND grant_type = 'tool' ORDER BY grant_ref",
personaID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
result = append(result, name)
}
return result, rows.Err()
}
// UserCanAccess checks if a user can see/use a specific Persona.
func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM personas WHERE id = $2 AND is_active = true AND (
scope = 'global'
OR (scope = 'personal' AND created_by = $1)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
OR (scope = 'personal' AND is_shared = true)
)
)`, userID, personaID).Scan(&exists)
return exists, err
}
// ── Scanners ────────────────────────────────
func scanPersona(row *sql.Row) (*models.Persona, error) {
var p models.Persona
var providerConfigID, ownerID sql.NullString
var temp, topP sql.NullFloat64
var maxTokens, thinkingBudget sql.NullInt64
err := row.Scan(
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
&p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, err
}
p.ProviderConfigID = NullableStringPtr(providerConfigID)
p.OwnerID = NullableStringPtr(ownerID)
p.Temperature = NullableFloat64Ptr(temp)
p.MaxTokens = NullableIntPtr(maxTokens)
p.ThinkingBudget = NullableIntPtr(thinkingBudget)
p.TopP = NullableFloat64Ptr(topP)
return &p, nil
}
func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
var result []models.Persona
for rows.Next() {
var p models.Persona
var providerConfigID, ownerID sql.NullString
var temp, topP sql.NullFloat64
var maxTokens, thinkingBudget sql.NullInt64
err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
&p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, err
}
p.ProviderConfigID = NullableStringPtr(providerConfigID)
p.OwnerID = NullableStringPtr(ownerID)
p.Temperature = NullableFloat64Ptr(temp)
p.MaxTokens = NullableIntPtr(maxTokens)
p.ThinkingBudget = NullableIntPtr(thinkingBudget)
p.TopP = NullableFloat64Ptr(topP)
result = append(result, p)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,68 @@
package postgres
import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type PolicyStore struct{}
func NewPolicyStore() *PolicyStore { return &PolicyStore{} }
// Get returns a policy value by key. Falls back to PolicyDefaults if not in DB.
func (s *PolicyStore) Get(ctx context.Context, key string) (string, error) {
var value string
err := DB.QueryRowContext(ctx,
"SELECT value FROM platform_policies WHERE key = $1", key).Scan(&value)
if err == sql.ErrNoRows {
if def, ok := models.PolicyDefaults[key]; ok {
return def, nil
}
return "", nil
}
return value, err
}
// GetBool returns a policy value as a boolean.
func (s *PolicyStore) GetBool(ctx context.Context, key string) (bool, error) {
val, err := s.Get(ctx, key)
if err != nil {
return false, err
}
return val == "true", nil
}
// Set upserts a policy value.
func (s *PolicyStore) Set(ctx context.Context, key, value, updatedBy string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO platform_policies (key, value, updated_by, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_by = $3, updated_at = NOW()`,
key, value, updatedBy)
return err
}
// GetAll returns all platform policies, merged with defaults.
func (s *PolicyStore) GetAll(ctx context.Context) (map[string]string, error) {
result := make(map[string]string)
// Start with defaults
for k, v := range models.PolicyDefaults {
result[k] = v
}
// Override with DB values
rows, err := DB.QueryContext(ctx, "SELECT key, value FROM platform_policies")
if err != nil {
return result, err // return defaults on error
}
defer rows.Close()
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
continue
}
result[k] = v
}
return result, rows.Err()
}

View File

@@ -0,0 +1,194 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type ProviderStore struct{}
func NewProviderStore() *ProviderStore { return &ProviderStore{} }
const providerCols = `id, scope, owner_id, name, provider, endpoint, api_key_enc,
model_default, config, headers, settings, is_active, is_private, created_at, updated_at`
func (s *ProviderStore) Create(ctx context.Context, cfg *models.ProviderConfig) error {
return DB.QueryRowContext(ctx, `
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc,
model_default, config, headers, settings, is_active, is_private)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, created_at, updated_at`,
cfg.Scope, models.NullString(cfg.OwnerID), cfg.Name, cfg.Provider, cfg.Endpoint,
cfg.APIKeyEnc, cfg.ModelDefault,
ToJSON(cfg.Config), ToJSON(cfg.Headers), ToJSON(cfg.Settings),
cfg.IsActive, cfg.IsPrivate,
).Scan(&cfg.ID, &cfg.CreatedAt, &cfg.UpdatedAt)
}
func (s *ProviderStore) GetByID(ctx context.Context, id string) (*models.ProviderConfig, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM provider_configs WHERE id = $1", providerCols), id)
return scanProvider(row)
}
func (s *ProviderStore) Update(ctx context.Context, id string, patch models.ProviderConfigPatch) error {
b := NewUpdate("provider_configs")
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.Endpoint != nil {
b.Set("endpoint", *patch.Endpoint)
}
if patch.APIKeyEnc != nil {
b.Set("api_key_enc", *patch.APIKeyEnc)
}
if patch.ModelDefault != nil {
b.Set("model_default", *patch.ModelDefault)
}
if patch.Config != nil {
b.SetJSON("config", patch.Config)
}
if patch.Headers != nil {
b.SetJSON("headers", patch.Headers)
}
if patch.Settings != nil {
b.SetJSON("settings", patch.Settings)
}
if patch.IsActive != nil {
b.Set("is_active", *patch.IsActive)
}
if patch.IsPrivate != nil {
b.Set("is_private", *patch.IsPrivate)
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *ProviderStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM provider_configs WHERE id = $1", id)
return err
}
func (s *ProviderStore) ListGlobal(ctx context.Context) ([]models.ProviderConfig, error) {
return s.listByScope(ctx, models.ScopeGlobal, "")
}
func (s *ProviderStore) ListForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) {
return s.listByScope(ctx, models.ScopeTeam, teamID)
}
func (s *ProviderStore) ListForUser(ctx context.Context, userID string) ([]models.ProviderConfig, error) {
return s.listByScope(ctx, models.ScopePersonal, userID)
}
// ListAccessible returns all provider configs a user can access:
// global + team (for teams they belong to) + personal.
func (s *ProviderStore) ListAccessible(ctx context.Context, userID string) ([]models.ProviderConfig, error) {
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
SELECT %s FROM provider_configs
WHERE is_active = true AND (
scope = 'global'
OR (scope = 'personal' AND owner_id = $1)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
)
ORDER BY scope, name`, providerCols), userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProviders(rows)
}
// UserCanAccess checks if a user can use a specific provider config.
func (s *ProviderStore) UserCanAccess(ctx context.Context, userID, configID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM provider_configs
WHERE id = $2 AND is_active = true AND (
scope = 'global'
OR (scope = 'personal' AND owner_id = $1)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
)
)`, userID, configID).Scan(&exists)
return exists, err
}
// ── Internal helpers ────────────────────────
func (s *ProviderStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ProviderConfig, error) {
var rows *sql.Rows
var err error
if scope == models.ScopeGlobal {
rows, err = DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = $1 AND is_active = true ORDER BY name", providerCols),
scope)
} else {
rows, err = DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = $1 AND owner_id = $2 AND is_active = true ORDER BY name", providerCols),
scope, ownerID)
}
if err != nil {
return nil, err
}
defer rows.Close()
return scanProviders(rows)
}
func scanProvider(row *sql.Row) (*models.ProviderConfig, error) {
var p models.ProviderConfig
var ownerID, modelDefault sql.NullString
var configJSON, headersJSON, settingsJSON []byte
err := row.Scan(
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
&p.APIKeyEnc, &modelDefault,
&configJSON, &headersJSON, &settingsJSON,
&p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, err
}
p.OwnerID = NullableStringPtr(ownerID)
p.ModelDefault = modelDefault.String
json.Unmarshal(configJSON, &p.Config)
json.Unmarshal(headersJSON, &p.Headers)
json.Unmarshal(settingsJSON, &p.Settings)
return &p, nil
}
func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) {
var result []models.ProviderConfig
for rows.Next() {
var p models.ProviderConfig
var ownerID, modelDefault sql.NullString
var configJSON, headersJSON, settingsJSON []byte
err := rows.Scan(
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
&p.APIKeyEnc, &modelDefault,
&configJSON, &headersJSON, &settingsJSON,
&p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, err
}
p.OwnerID = NullableStringPtr(ownerID)
p.ModelDefault = modelDefault.String
json.Unmarshal(configJSON, &p.Config)
json.Unmarshal(headersJSON, &p.Headers)
json.Unmarshal(settingsJSON, &p.Settings)
result = append(result, p)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,27 @@
package postgres
import (
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// NewStores creates all Postgres store implementations and wires them
// into the Stores bundle. Call this at startup after database.Connect().
func NewStores(db *sql.DB) store.Stores {
SetDB(db)
return store.Stores{
Providers: NewProviderStore(),
Catalog: NewCatalogStore(),
Personas: NewPersonaStore(),
Policies: NewPolicyStore(),
UserSettings: NewUserModelSettingsStore(),
Users: NewUserStore(),
Teams: NewTeamStore(),
Channels: NewChannelStore(),
Messages: NewMessageStore(),
Audit: NewAuditStore(),
Notes: NewNoteStore(),
GlobalConfig: NewGlobalConfigStore(),
}
}

View File

@@ -0,0 +1,198 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type TeamStore struct{}
func NewTeamStore() *TeamStore { return &TeamStore{} }
func (s *TeamStore) Create(ctx context.Context, t *models.Team) error {
settingsJSON := ToJSON(t.Settings)
return DB.QueryRowContext(ctx, `
INSERT INTO teams (name, description, created_by, is_active, settings)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at, updated_at`,
t.Name, t.Description, t.CreatedBy, t.IsActive, settingsJSON,
).Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt)
}
func (s *TeamStore) GetByID(ctx context.Context, id string) (*models.Team, error) {
var t models.Team
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams WHERE id = $1`, id).Scan(
&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MemberCount,
)
if err != nil {
return nil, err
}
json.Unmarshal(settingsJSON, &t.Settings)
return &t, nil
}
func (s *TeamStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("teams")
for k, v := range fields {
if k == "settings" {
b.SetJSON(k, v)
} else {
b.Set(k, v)
}
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *TeamStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM teams WHERE id = $1", id)
return err
}
func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Team
for rows.Next() {
var t models.Team
var settingsJSON []byte
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MemberCount)
if err != nil {
return nil, err
}
json.Unmarshal(settingsJSON, &t.Settings)
result = append(result, t)
}
return result, rows.Err()
}
// ── Members ─────────────────────────────────
func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)
ON CONFLICT (team_id, user_id) DO UPDATE SET role = $3`,
teamID, userID, role)
return err
}
func (s *TeamStore) RemoveMember(ctx context.Context, teamID, userID string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM team_members WHERE team_id = $1 AND user_id = $2",
teamID, userID)
return err
}
func (s *TeamStore) UpdateMemberRole(ctx context.Context, teamID, userID, role string) error {
_, err := DB.ExecContext(ctx,
"UPDATE team_members SET role = $1 WHERE team_id = $2 AND user_id = $3",
role, teamID, userID)
return err
}
func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) {
rows, err := DB.QueryContext(ctx, `
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = $1
ORDER BY tm.role DESC, u.username`, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.TeamMember
for rows.Next() {
var m models.TeamMember
err := rows.Scan(&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
if err != nil {
return nil, err
}
result = append(result, m)
}
return result, rows.Err()
}
func (s *TeamStore) GetMember(ctx context.Context, teamID, userID string) (*models.TeamMember, error) {
var m models.TeamMember
err := DB.QueryRowContext(ctx, `
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = $1 AND tm.user_id = $2`, teamID, userID).Scan(
&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
if err != nil {
return nil, err
}
return &m, nil
}
// GetUserTeamIDs returns all team IDs a user belongs to.
func (s *TeamStore) GetUserTeamIDs(ctx context.Context, userID string) ([]string, error) {
rows, err := DB.QueryContext(ctx,
"SELECT team_id FROM team_members WHERE user_id = $1", userID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *TeamStore) IsTeamAdmin(ctx context.Context, teamID, userID string) (bool, error) {
var role string
err := DB.QueryRowContext(ctx,
"SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2",
teamID, userID).Scan(&role)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return role == models.TeamRoleAdmin, nil
}
func (s *TeamStore) IsMember(ctx context.Context, teamID, userID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)",
teamID, userID).Scan(&exists)
return exists, err
}
// unused but keeping for reference
var _ = fmt.Sprintf

View File

@@ -0,0 +1,185 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type UserStore struct{}
func NewUserStore() *UserStore { return &UserStore{} }
func (s *UserStore) Create(ctx context.Context, u *models.User) error {
return DB.QueryRowContext(ctx, `
INSERT INTO users (username, email, password_hash, display_name, role, is_active, settings)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at, updated_at`,
u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, ToJSON(u.Settings),
).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
}
func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error) {
return s.getBy(ctx, "id", id)
}
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
return s.getBy(ctx, "username", username)
}
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*models.User, error) {
return s.getBy(ctx, "email", email)
}
func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, error) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE username = $1 OR email = $1`, login).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
}
func (s *UserStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("users")
for k, v := range fields {
if k == "settings" {
b.SetJSON(k, v)
} else {
b.Set(k, v)
}
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *UserStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", id)
return err
}
func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.User, int, error) {
b := NewSelect(
"id, username, email, display_name, avatar_url, role, is_active, settings, created_at, updated_at, last_login_at",
"users",
)
if opts.Sort == "" {
b.OrderBy("username", "ASC")
}
b.Paginate(opts)
// Count
var total int
DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&total)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var result []models.User
for rows.Next() {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := rows.Scan(&u.ID, &u.Username, &u.Email, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt)
if err != nil {
return nil, 0, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
result = append(result, u)
}
return result, total, rows.Err()
}
func (s *UserStore) UpdateLastLogin(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "UPDATE users SET last_login_at = NOW() WHERE id = $1", id)
return err
}
func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error {
_, err := DB.ExecContext(ctx, "UPDATE users SET is_active = $1 WHERE id = $2", active, id)
return err
}
// ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt)
return err
}
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
var userID string
err := DB.QueryRowContext(ctx, `
SELECT user_id FROM refresh_tokens
WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`,
tokenHash).Scan(&userID)
return userID, err
}
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash)
return err
}
func (s *UserStore) RevokeAllRefreshTokens(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", userID)
return err
}
func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
_, err := DB.ExecContext(ctx,
"DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'")
return err
}
// ── Internal ────────────────────────────────
func (s *UserStore) getBy(ctx context.Context, col, val string) (*models.User, error) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, fmt.Sprintf(`
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE %s = $1`, col), val).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
}

View File

@@ -0,0 +1,163 @@
package postgres
import (
"context"
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type UserModelSettingsStore struct{}
func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSettingsStore{} }
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens,
sort_order, created_at, updated_at
FROM user_model_settings WHERE user_id = $1 ORDER BY sort_order, model_id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.UserModelSetting
for rows.Next() {
var s models.UserModelSetting
var prefTemp, prefMaxTokens interface{}
err := rows.Scan(
&s.ID, &s.UserID, &s.ModelID, &s.Hidden,
&prefTemp, &prefMaxTokens,
&s.SortOrder, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
if f, ok := prefTemp.(float64); ok {
s.PreferredTemperature = &f
}
if n, ok := prefMaxTokens.(int64); ok {
v := int(n)
s.PreferredMaxTokens = &v
}
result = append(result, s)
}
return result, rows.Err()
}
// GetHiddenModelIDs returns a map of model_id → true for all hidden models.
func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
rows, err := DB.QueryContext(ctx,
"SELECT model_id FROM user_model_settings WHERE user_id = $1 AND hidden = true",
userID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]bool)
for rows.Next() {
var modelID string
if err := rows.Scan(&modelID); err != nil {
return nil, err
}
result[modelID] = true
}
return result, rows.Err()
}
// Set upserts a single user model setting.
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error {
b := NewUpdate("user_model_settings")
if patch.Hidden != nil {
b.Set("hidden", *patch.Hidden)
}
if patch.PreferredTemperature != nil {
b.Set("preferred_temperature", models.NullFloat(patch.PreferredTemperature))
}
if patch.PreferredMaxTokens != nil {
b.Set("preferred_max_tokens", models.NullInt(patch.PreferredMaxTokens))
}
if patch.SortOrder != nil {
b.Set("sort_order", *patch.SortOrder)
}
if !b.HasSets() {
return nil
}
// Use upsert: insert if not exists, update if exists
// Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT
_, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, model_id)
DO UPDATE SET
hidden = COALESCE($3, user_model_settings.hidden),
preferred_temperature = COALESCE($4, user_model_settings.preferred_temperature),
preferred_max_tokens = COALESCE($5, user_model_settings.preferred_max_tokens),
sort_order = COALESCE($6, user_model_settings.sort_order)`,
userID, modelID,
patchBoolOrNil(patch.Hidden),
patchFloat64OrNil(patch.PreferredTemperature),
patchIntOrNil(patch.PreferredMaxTokens),
patchIntOrNil(patch.SortOrder),
)
return err
}
// BulkSetHidden sets the hidden state for multiple models at once.
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error {
if len(modelIDs) == 0 {
return nil
}
// Build parameterized IN clause
placeholders := make([]string, len(modelIDs))
args := make([]interface{}, 0, len(modelIDs)+2)
args = append(args, userID, hidden)
for i, id := range modelIDs {
placeholders[i] = fmt.Sprintf("$%d", i+3)
args = append(args, id)
}
// Upsert each: some might not have rows yet
for _, modelID := range modelIDs {
_, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (user_id, model_id, hidden)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = $3`,
userID, modelID, hidden)
if err != nil {
return fmt.Errorf("set hidden for %s: %w", modelID, err)
}
}
return nil
}
// ── Helpers ─────────────────────────────────
func patchBoolOrNil(b *bool) interface{} {
if b == nil {
return nil
}
return *b
}
func patchFloat64OrNil(f *float64) interface{} {
if f == nil {
return nil
}
return *f
}
func patchIntOrNil(i *int) interface{} {
if i == nil {
return nil
}
return *i
}
// unused but keeping for reference - will be used in ListOptions-based queries
var _ = strings.Join

View File

@@ -1,203 +0,0 @@
package tools
import (
"context"
"encoding/json"
"os"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
func TestMain(m *testing.M) {
teardown := database.SetupTestDB()
code := m.Run()
teardown()
os.Exit(code)
}
// ── Note Tool Execution (requires DB) ───────
func TestNoteCreateExecute(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "tooluser", "tool@test.com")
channelID := database.SeedTestChannel(t, userID, "Tool Test")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
tool := Get("note_create")
if tool == nil {
t.Fatal("note_create not registered")
}
args := `{"title":"Test Note","content":"Created via tool","folder":"/tools","tags":["test","ci"]}`
result, err := tool.Execute(ctx, execCtx, args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(result), &resp); err != nil {
t.Fatalf("Invalid JSON result: %v\nRaw: %s", err, result)
}
if resp["id"] == nil || resp["id"] == "" {
t.Error("Expected id in result")
}
if resp["title"] != "Test Note" {
t.Errorf("Expected title='Test Note', got %v", resp["title"])
}
}
func TestNoteListExecute(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "listuser", "list@test.com")
channelID := database.SeedTestChannel(t, userID, "List Test")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
// Create two notes first
createTool := Get("note_create")
createTool.Execute(ctx, execCtx, `{"title":"Note A","content":"Alpha","folder":"/a"}`)
createTool.Execute(ctx, execCtx, `{"title":"Note B","content":"Beta","folder":"/b"}`)
// List all
listTool := Get("note_list")
result, err := listTool.Execute(ctx, execCtx, `{}`)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var resp map[string]interface{}
json.Unmarshal([]byte(result), &resp)
count := resp["count"].(float64)
if count != 2 {
t.Errorf("Expected count=2, got %.0f", count)
}
// List filtered by folder
result, err = listTool.Execute(ctx, execCtx, `{"folder":"/a"}`)
if err != nil {
t.Fatalf("Execute with folder: %v", err)
}
json.Unmarshal([]byte(result), &resp)
count = resp["count"].(float64)
if count != 1 {
t.Errorf("Expected count=1 for folder /a, got %.0f", count)
}
}
func TestNoteSearchExecute(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "searchuser", "search@test.com")
channelID := database.SeedTestChannel(t, userID, "Search Test")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
createTool := Get("note_create")
createTool.Execute(ctx, execCtx, `{"title":"Kubernetes Guide","content":"How to deploy pods and services"}`)
createTool.Execute(ctx, execCtx, `{"title":"Cooking Tips","content":"Season your cast iron pan properly"}`)
searchTool := Get("note_search")
result, err := searchTool.Execute(ctx, execCtx, `{"query":"kubernetes pods"}`)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var resp map[string]interface{}
json.Unmarshal([]byte(result), &resp)
count := resp["count"].(float64)
if count != 1 {
t.Errorf("Expected 1 search result for 'kubernetes pods', got %.0f", count)
}
}
func TestNoteUpdateExecute(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "updateuser", "update@test.com")
channelID := database.SeedTestChannel(t, userID, "Update Test")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
// Create a note
createTool := Get("note_create")
result, _ := createTool.Execute(ctx, execCtx, `{"title":"Original","content":"Original content"}`)
var created map[string]interface{}
json.Unmarshal([]byte(result), &created)
noteID := created["id"].(string)
// Update title
updateTool := Get("note_update")
result, err := updateTool.Execute(ctx, execCtx, `{"note_id":"`+noteID+`","title":"Renamed"}`)
if err != nil {
t.Fatalf("Update title: %v", err)
}
var updated map[string]interface{}
json.Unmarshal([]byte(result), &updated)
if updated["title"] != "Renamed" {
t.Errorf("Expected title='Renamed', got %v", updated["title"])
}
// Append content
result, err = updateTool.Execute(ctx, execCtx, `{"note_id":"`+noteID+`","content":"\nNew line","mode":"append"}`)
if err != nil {
t.Fatalf("Append: %v", err)
}
// Verify via direct DB read
var row string
database.DB.QueryRow("SELECT content FROM notes WHERE id = $1", noteID).Scan(&row)
if row != "Original content\nNew line" {
t.Errorf("Append: expected concatenated content, got %q", row)
}
}
func TestNoteCreateMissingRequiredField(t *testing.T) {
database.RequireTestDB(t)
ctx := context.Background()
execCtx := ExecutionContext{UserID: "test", ChannelID: "test"}
tool := Get("note_create")
// Missing content
result, err := tool.Execute(ctx, execCtx, `{"title":"No Content"}`)
if err == nil {
// Tools return errors in content, not as Go errors
var resp map[string]interface{}
json.Unmarshal([]byte(result), &resp)
if resp["error"] == nil {
t.Error("Expected error for missing content")
}
}
}
func TestNoteUpdateNonexistent(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "ghostuser", "ghost@test.com")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: "test"}
tool := Get("note_update")
_, err := tool.Execute(ctx, execCtx, `{"note_id":"00000000-0000-0000-0000-000000000000","title":"Nope"}`)
if err == nil {
t.Log("Update of nonexistent note should return error or empty result")
}
}