Changeset 0.17.1 (#76)
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
type Config struct {
|
||||
Port string
|
||||
DatabaseURL string
|
||||
DBDriver string // "postgres" (default) or "sqlite"
|
||||
JWTSecret string
|
||||
Environment string
|
||||
BasePath string // URL path prefix (e.g. "/dev", "/test", or "" for root)
|
||||
@@ -60,6 +61,7 @@ func Load() *Config {
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
DBDriver: getEnv("DB_DRIVER", ""),
|
||||
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
|
||||
Environment: getEnv("ENVIRONMENT", "development"),
|
||||
BasePath: sanitizeBasePath(getEnv("BASE_PATH", "")),
|
||||
|
||||
209
server/database/compat.go
Normal file
209
server/database/compat.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ── SQL Dialect Adapter ─────────────────────
|
||||
//
|
||||
// Q adapts Postgres-flavoured SQL to the current dialect.
|
||||
// On Postgres it is a no-op. On SQLite it rewrites:
|
||||
// - $1, $2 … $20 → ?
|
||||
// - ::jsonb, ::text → removed
|
||||
// - NOW() → datetime('now')
|
||||
// - true / false → 1 / 0 (bare keywords only, not inside strings)
|
||||
// - NULLS LAST → removed (SQLite default)
|
||||
// - COALESCE(x::text, …) → COALESCE(x, …)
|
||||
//
|
||||
// Call it at every database.DB.Query / Exec / QueryRow site in handlers.
|
||||
func Q(query string) string {
|
||||
if !IsSQLite() {
|
||||
return query
|
||||
}
|
||||
q := query
|
||||
|
||||
// Replace $N placeholders high-to-low to avoid $1 matching inside $10.
|
||||
for i := 20; i >= 1; i-- {
|
||||
q = strings.ReplaceAll(q, fmt.Sprintf("$%d", i), "?")
|
||||
}
|
||||
|
||||
// Postgres type casts
|
||||
q = strings.ReplaceAll(q, "::jsonb", "")
|
||||
q = strings.ReplaceAll(q, "::text", "")
|
||||
|
||||
// Time functions
|
||||
q = strings.ReplaceAll(q, "NOW()", "datetime('now')")
|
||||
|
||||
// Boolean literals (bare keywords, not inside quotes)
|
||||
q = strings.ReplaceAll(q, "= true", "= 1")
|
||||
q = strings.ReplaceAll(q, "= false", "= 0")
|
||||
q = strings.ReplaceAll(q, "COALESCE(is_private, false)", "COALESCE(is_private, 0)")
|
||||
|
||||
// NULL sort order (SQLite puts NULLs last by default for DESC)
|
||||
q = strings.ReplaceAll(q, "NULLS LAST", "")
|
||||
|
||||
// Case-insensitive LIKE (SQLite LIKE is already case-insensitive for ASCII)
|
||||
q = strings.ReplaceAll(q, "ILIKE", "LIKE")
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
// InsertReturningID executes an INSERT … RETURNING id query.
|
||||
// On Postgres it uses RETURNING directly.
|
||||
// On SQLite it strips RETURNING, generates a UUID, prepends it to the args,
|
||||
// adds an "id" column to the INSERT, and executes.
|
||||
func InsertReturningID(query string, args ...interface{}) (string, error) {
|
||||
if !IsSQLite() {
|
||||
var id string
|
||||
err := DB.QueryRow(query, args...).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// Generate ID for SQLite
|
||||
id := uuid.New().String()
|
||||
|
||||
// Adapt SQL: strip RETURNING, convert placeholders, add id column+value
|
||||
q := Q(query)
|
||||
|
||||
// Remove RETURNING clause
|
||||
if idx := strings.Index(strings.ToUpper(q), "RETURNING"); idx >= 0 {
|
||||
q = strings.TrimSpace(q[:idx])
|
||||
}
|
||||
|
||||
// Inject id column and value into INSERT
|
||||
// Pattern: INSERT INTO table (col1, col2, ...) VALUES (?, ?, ...)
|
||||
q = injectIDColumn(q)
|
||||
|
||||
// Prepend id to args
|
||||
newArgs := make([]interface{}, 0, len(args)+1)
|
||||
newArgs = append(newArgs, id)
|
||||
newArgs = append(newArgs, args...)
|
||||
|
||||
_, err := DB.Exec(q, newArgs...)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// InjectIDForTest adds "id" as the first column and "?" as the first value
|
||||
// in an INSERT statement. Exported for use in test helpers.
|
||||
func InjectIDForTest(q string) string {
|
||||
return injectIDColumn(q)
|
||||
}
|
||||
|
||||
// injectIDColumn adds "id" as the first column and "?" as the first value
|
||||
// in an INSERT statement.
|
||||
func injectIDColumn(q string) string {
|
||||
// Find the opening paren of the column list
|
||||
upper := strings.ToUpper(q)
|
||||
insertIdx := strings.Index(upper, "INSERT INTO")
|
||||
if insertIdx < 0 {
|
||||
return q
|
||||
}
|
||||
|
||||
// Find first ( after INSERT INTO tablename
|
||||
firstParen := strings.Index(q[insertIdx:], "(")
|
||||
if firstParen < 0 {
|
||||
return q
|
||||
}
|
||||
firstParen += insertIdx
|
||||
|
||||
// Find VALUES (
|
||||
valuesIdx := strings.Index(upper, "VALUES")
|
||||
if valuesIdx < 0 {
|
||||
return q
|
||||
}
|
||||
valuesParen := strings.Index(q[valuesIdx:], "(")
|
||||
if valuesParen < 0 {
|
||||
return q
|
||||
}
|
||||
valuesParen += valuesIdx
|
||||
|
||||
// Insert "id, " after first ( and "?, " after VALUES (
|
||||
result := q[:firstParen+1] + "id, " + q[firstParen+1:valuesParen+1] + "?, " + q[valuesParen+1:]
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Time Scan Helpers ───────────────────────
|
||||
//
|
||||
// SQLite returns timestamps as TEXT strings. These helpers wrap *time.Time
|
||||
// and sql.NullTime destinations so Scan() works on both dialects.
|
||||
|
||||
// ScanTime wraps a *time.Time for use in handler-level Scan() calls.
|
||||
// On Postgres, time.Time scans natively. On SQLite, parses from string.
|
||||
type ScanTime struct{ T *time.Time }
|
||||
|
||||
func (s *ScanTime) Scan(src interface{}) error {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
switch v := src.(type) {
|
||||
case time.Time:
|
||||
*s.T = v
|
||||
return nil
|
||||
case string:
|
||||
for _, f := range compatTimeFormats {
|
||||
if p, err := time.Parse(f, v); err == nil {
|
||||
*s.T = p
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("ScanTime: cannot parse %q", v)
|
||||
default:
|
||||
return fmt.Errorf("ScanTime: unsupported type %T", src)
|
||||
}
|
||||
}
|
||||
|
||||
// ST creates a ScanTime wrapper for use in Scan() argument lists.
|
||||
func ST(t *time.Time) *ScanTime { return &ScanTime{T: t} }
|
||||
|
||||
// ScanNullTime wraps a nullable time destination.
|
||||
type ScanNullTime struct{ T **time.Time }
|
||||
|
||||
func (s *ScanNullTime) Scan(src interface{}) error {
|
||||
if src == nil {
|
||||
*s.T = nil
|
||||
return nil
|
||||
}
|
||||
var parsed time.Time
|
||||
scanner := ST(&parsed)
|
||||
if err := scanner.Scan(src); err != nil {
|
||||
return err
|
||||
}
|
||||
*s.T = &parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// SNT creates a ScanNullTime wrapper for use in Scan() argument lists.
|
||||
func SNT(t **time.Time) *ScanNullTime { return &ScanNullTime{T: t} }
|
||||
|
||||
// NullTimeValue extracts a time.Time from sql.NullTime or *time.Time,
|
||||
// returning the zero value if nil/invalid. Useful in JSON serialization.
|
||||
func NullTimeValue(t *time.Time) time.Time {
|
||||
if t == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return *t
|
||||
}
|
||||
|
||||
var compatTimeFormats = []string{
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05Z",
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05.000000000+00:00",
|
||||
}
|
||||
|
||||
// ── Unique Constraint Detection ─────────────
|
||||
|
||||
// IsUniqueViolation checks if an error is a unique constraint violation,
|
||||
// working on both Postgres and SQLite.
|
||||
func IsUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "duplicate key") || // Postgres
|
||||
strings.Contains(msg, "UNIQUE constraint failed") // SQLite
|
||||
}
|
||||
@@ -4,8 +4,12 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
)
|
||||
@@ -13,21 +17,38 @@ import (
|
||||
// DB is the application-wide database connection pool.
|
||||
var DB *sql.DB
|
||||
|
||||
// Connect opens a connection pool to PostgreSQL using the
|
||||
// DATABASE_URL from config. It pings to verify connectivity.
|
||||
// Connect opens a connection pool to the configured database.
|
||||
// Driver is selected by DB_DRIVER env var: "postgres" (default) or "sqlite".
|
||||
// For SQLite the DATABASE_URL is treated as a file path; ":memory:" is supported.
|
||||
func Connect(cfg *config.Config) error {
|
||||
if cfg.DatabaseURL == "" {
|
||||
driver := strings.ToLower(cfg.DBDriver)
|
||||
if driver == "" {
|
||||
driver = detectDriver(cfg.DatabaseURL)
|
||||
}
|
||||
|
||||
switch driver {
|
||||
case "sqlite", "sqlite3":
|
||||
return connectSQLite(cfg.DatabaseURL)
|
||||
case "postgres", "postgresql", "":
|
||||
return connectPostgres(cfg.DatabaseURL)
|
||||
default:
|
||||
return fmt.Errorf("unsupported DB_DRIVER: %q (expected postgres or sqlite)", driver)
|
||||
}
|
||||
}
|
||||
|
||||
// connectPostgres opens a PostgreSQL connection pool.
|
||||
func connectPostgres(dsn string) error {
|
||||
if dsn == "" {
|
||||
log.Println("⚠ DATABASE_URL not set — running without database")
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
DB, err = sql.Open("postgres", cfg.DatabaseURL)
|
||||
DB, err = sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("database open: %w", err)
|
||||
}
|
||||
|
||||
// Connection pool tuning
|
||||
DB.SetMaxOpenConns(25)
|
||||
DB.SetMaxIdleConns(5)
|
||||
|
||||
@@ -35,10 +56,75 @@ func Connect(cfg *config.Config) error {
|
||||
return fmt.Errorf("database ping: %w", err)
|
||||
}
|
||||
|
||||
log.Println("✅ Database connected")
|
||||
CurrentDialect = DialectPostgres
|
||||
log.Println("✅ Database connected (postgres)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectSQLite opens a SQLite database with WAL mode enabled.
|
||||
func connectSQLite(dsn string) error {
|
||||
if dsn == "" {
|
||||
dsn = "switchboard.db"
|
||||
}
|
||||
|
||||
// Ensure parent directory exists for file-based DBs.
|
||||
if dsn != ":memory:" && !strings.HasPrefix(dsn, "file:") {
|
||||
dir := filepath.Dir(dsn)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create db directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// modernc.org/sqlite uses "sqlite" as the driver name.
|
||||
// Append pragmas via query string for file paths.
|
||||
// _time_format tells the driver how to convert between Go time.Time and SQLite TEXT timestamps.
|
||||
connStr := dsn
|
||||
if dsn != ":memory:" && !strings.Contains(dsn, "?") {
|
||||
connStr = fmt.Sprintf("file:%s?_pragma=journal_mode%%3Dwal&_pragma=busy_timeout%%3D5000&_pragma=foreign_keys%%3Don&_time_format=2006-01-02T15%%3A04%%3A05Z", dsn)
|
||||
} else if dsn == ":memory:" {
|
||||
connStr = "file::memory:?_pragma=foreign_keys%%3Don&_time_format=2006-01-02T15%%3A04%%3A05Z"
|
||||
}
|
||||
|
||||
var err error
|
||||
DB, err = sql.Open("sqlite", connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("database open: %w", err)
|
||||
}
|
||||
|
||||
// SQLite performs best with a single writer.
|
||||
DB.SetMaxOpenConns(1)
|
||||
DB.SetMaxIdleConns(1)
|
||||
|
||||
if err := DB.Ping(); err != nil {
|
||||
return fmt.Errorf("database ping: %w", err)
|
||||
}
|
||||
|
||||
// Verify WAL mode took effect (some in-memory modes ignore it).
|
||||
var mode string
|
||||
DB.QueryRow("PRAGMA journal_mode").Scan(&mode)
|
||||
log.Printf("✅ Database connected (sqlite, journal_mode=%s, path=%s)", mode, dsn)
|
||||
|
||||
CurrentDialect = DialectSQLite
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectDriver infers the driver from DATABASE_URL format.
|
||||
func detectDriver(dsn string) string {
|
||||
if dsn == "" {
|
||||
return "postgres"
|
||||
}
|
||||
if strings.HasPrefix(dsn, "postgres") {
|
||||
return "postgres"
|
||||
}
|
||||
if strings.HasSuffix(dsn, ".db") || strings.HasSuffix(dsn, ".sqlite") ||
|
||||
dsn == ":memory:" || strings.HasPrefix(dsn, "file:") {
|
||||
return "sqlite"
|
||||
}
|
||||
return "postgres"
|
||||
}
|
||||
|
||||
// Close gracefully shuts down the connection pool.
|
||||
func Close() {
|
||||
if DB != nil {
|
||||
|
||||
31
server/database/dialect.go
Normal file
31
server/database/dialect.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package database
|
||||
|
||||
// Dialect identifies the active database backend.
|
||||
type Dialect int
|
||||
|
||||
const (
|
||||
DialectPostgres Dialect = iota
|
||||
DialectSQLite
|
||||
)
|
||||
|
||||
// CurrentDialect is set during Connect() and read by store constructors
|
||||
// to select the appropriate SQL flavour.
|
||||
var CurrentDialect Dialect
|
||||
|
||||
// String returns a human-readable label.
|
||||
func (d Dialect) String() string {
|
||||
switch d {
|
||||
case DialectPostgres:
|
||||
return "postgres"
|
||||
case DialectSQLite:
|
||||
return "sqlite"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// IsPostgres returns true when connected to PostgreSQL.
|
||||
func IsPostgres() bool { return CurrentDialect == DialectPostgres }
|
||||
|
||||
// IsSQLite returns true when connected to SQLite.
|
||||
func IsSQLite() bool { return CurrentDialect == DialectSQLite }
|
||||
@@ -16,26 +16,31 @@ var schemaVersion string = "none"
|
||||
// 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.
|
||||
// Migrate runs all pending migrations for the current dialect.
|
||||
// Migration files live in migrations/<dialect>/ subdirectories.
|
||||
// Falls back to the root migrations/ directory if no subdirectory exists
|
||||
// (backward compatible with pre-v0.17.1 postgres-only layouts).
|
||||
func Migrate() error {
|
||||
if DB == nil {
|
||||
return fmt.Errorf("database not connected")
|
||||
}
|
||||
|
||||
// Ensure tracking table exists
|
||||
_, err := DB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
// Create tracking table — syntax compatible with both drivers.
|
||||
createMigrations := `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TEXT DEFAULT (datetime('now'))
|
||||
)`
|
||||
if IsPostgres() {
|
||||
createMigrations = `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
)`
|
||||
}
|
||||
if _, err := DB.Exec(createMigrations); err != nil {
|
||||
return fmt.Errorf("create migrations table: %w", err)
|
||||
}
|
||||
|
||||
// Find migration files
|
||||
// Locate migration files for current dialect.
|
||||
migrationsDir := findMigrationsDir()
|
||||
if migrationsDir == "" {
|
||||
log.Println("⚠ No migrations directory found — skipping schema migration")
|
||||
@@ -47,7 +52,6 @@ func Migrate() error {
|
||||
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") {
|
||||
@@ -62,14 +66,15 @@ func Migrate() error {
|
||||
}
|
||||
|
||||
// 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)
|
||||
prefix := strings.SplitN(file, "_", 2)[0]
|
||||
DB.Exec("UPDATE schema_migrations SET version = ? WHERE version = ? AND version != ?", file, prefix, file)
|
||||
if IsPostgres() {
|
||||
DB.Exec("UPDATE schema_migrations SET version = $1 WHERE version = $2 AND version != $1", file, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply pending migrations
|
||||
// Apply pending migrations.
|
||||
applied := 0
|
||||
for _, file := range files {
|
||||
version := extractVersion(file)
|
||||
@@ -77,29 +82,36 @@ func Migrate() error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if already applied
|
||||
var exists bool
|
||||
DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists)
|
||||
if IsPostgres() {
|
||||
DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists)
|
||||
} else {
|
||||
DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = ?)", version).Scan(&exists)
|
||||
}
|
||||
if exists {
|
||||
schemaVersion = version
|
||||
continue
|
||||
}
|
||||
|
||||
// Read and execute
|
||||
path := filepath.Join(migrationsDir, file)
|
||||
sql, err := os.ReadFile(path)
|
||||
sqlBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", file, err)
|
||||
}
|
||||
|
||||
log.Printf(" Applying migration %s...", file)
|
||||
if _, err := DB.Exec(string(sql)); err != nil {
|
||||
if _, err := DB.Exec(string(sqlBytes)); err != nil {
|
||||
return fmt.Errorf("apply %s: %w", file, 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 IsPostgres() {
|
||||
if _, err := DB.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil {
|
||||
return fmt.Errorf("record %s: %w", file, err)
|
||||
}
|
||||
} else {
|
||||
if _, err := DB.Exec("INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
|
||||
return fmt.Errorf("record %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
schemaVersion = version
|
||||
@@ -117,38 +129,49 @@ func Migrate() error {
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
// Must start with a digit (e.g., 001_v09_schema.sql)
|
||||
if len(filename) == 0 || filename[0] < '0' || filename[0] > '9' {
|
||||
return ""
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
// findMigrationsDir locates the migrations directory.
|
||||
// Checks relative to the binary, then relative to the source file.
|
||||
// findMigrationsDir locates the dialect-specific migrations directory.
|
||||
// Looks for migrations/<dialect>/ first, then falls back to migrations/.
|
||||
func findMigrationsDir() string {
|
||||
candidates := []string{
|
||||
dialectDir := CurrentDialect.String() // "postgres" or "sqlite"
|
||||
|
||||
// Candidates: dialect-specific first, then generic.
|
||||
bases := []string{
|
||||
"database/migrations",
|
||||
"server/database/migrations",
|
||||
"../database/migrations",
|
||||
}
|
||||
|
||||
// Also check relative to this source file (for tests)
|
||||
// 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"))
|
||||
bases = append(bases, filepath.Join(dir, "migrations"))
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
if info, err := os.Stat(c); err == nil && info.IsDir() {
|
||||
return c
|
||||
// Try dialect-specific subdir first.
|
||||
for _, base := range bases {
|
||||
candidate := filepath.Join(base, dialectDir)
|
||||
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to root migrations/ (backward compat for postgres).
|
||||
if IsPostgres() {
|
||||
for _, base := range bases {
|
||||
if info, err := os.Stat(base); err == nil && info.IsDir() {
|
||||
return base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
server/database/migrations/sqlite/001_embedding_patch.sql
Normal file
22
server/database/migrations/sqlite/001_embedding_patch.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Schema patch for 001_v017_schema.sql
|
||||
-- Change the kb_chunks table to include the embedding column:
|
||||
--
|
||||
-- BEFORE:
|
||||
-- CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
-- ...
|
||||
-- token_count INTEGER NOT NULL DEFAULT 0,
|
||||
-- -- embedding column omitted: vector search feature-gated for SQLite.
|
||||
-- -- Embedding bytes can be stored as BLOB if sqlite-vec is available.
|
||||
-- metadata TEXT NOT NULL DEFAULT '{}',
|
||||
-- ...
|
||||
--
|
||||
-- AFTER:
|
||||
-- CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
-- ...
|
||||
-- token_count INTEGER NOT NULL DEFAULT 0,
|
||||
-- embedding TEXT, -- JSON array of float64 for app-level cosine similarity
|
||||
-- metadata TEXT NOT NULL DEFAULT '{}',
|
||||
-- ...
|
||||
--
|
||||
-- For existing SQLite databases, run:
|
||||
-- ALTER TABLE kb_chunks ADD COLUMN embedding TEXT;
|
||||
791
server/database/migrations/sqlite/001_v017_schema.sql
Normal file
791
server/database/migrations/sqlite/001_v017_schema.sql
Normal file
@@ -0,0 +1,791 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — v0.16.0 Consolidated Schema (SQLite)
|
||||
-- ==========================================
|
||||
-- SQLite equivalent of the Postgres schema.
|
||||
--
|
||||
-- Key differences from Postgres:
|
||||
-- • No extensions (pgcrypto, pgvector)
|
||||
-- • UUID generated application-side (Go uuid.New())
|
||||
-- • JSONB → TEXT (JSON stored as text)
|
||||
-- • TEXT[] / UUID[] → TEXT (JSON arrays as text)
|
||||
-- • TIMESTAMPTZ → TEXT (ISO 8601 strings)
|
||||
-- • BYTEA → BLOB
|
||||
-- • NUMERIC → REAL
|
||||
-- • TSVECTOR/VECTOR columns omitted (feature-gated)
|
||||
-- • No COMMENT ON statements
|
||||
-- • Triggers use SQLite syntax (WHEN guard to prevent recursion)
|
||||
-- • No partial index WHERE clauses on GIN indexes
|
||||
-- ==========================================
|
||||
|
||||
-- ── WAL mode (also set via connection pragma) ──
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- =========================================
|
||||
-- 1. USERS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
role TEXT DEFAULT 'user'
|
||||
CHECK (role IN ('user', 'admin')),
|
||||
is_active INTEGER DEFAULT 1,
|
||||
settings TEXT DEFAULT '{}',
|
||||
|
||||
-- Vault: per-user encryption key (UEK) for BYOK API keys
|
||||
encrypted_uek BLOB,
|
||||
uek_salt BLOB,
|
||||
uek_nonce BLOB,
|
||||
vault_set INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
last_login_at TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (LOWER(username));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (LOWER(email));
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS users_updated_at AFTER UPDATE ON users
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE users SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 2. AUTH
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
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 TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
settings TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_active ON teams(is_active) WHERE is_active = 1;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS teams_updated_at AFTER UPDATE ON teams
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE teams SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('admin', 'member')),
|
||||
joined_at TEXT DEFAULT (datetime('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. GROUPS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'global'
|
||||
CHECK (scope IN ('global', 'team')),
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
|
||||
CONSTRAINT groups_scope_team CHECK (
|
||||
(scope = 'global' AND team_id IS NULL) OR
|
||||
(scope = 'team' AND team_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope
|
||||
ON groups(name, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'));
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS groups_updated_at AFTER UPDATE ON groups
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE groups SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
added_by TEXT NOT NULL REFERENCES users(id),
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(group_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 5. PROVIDER CONFIGS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL
|
||||
CHECK (scope IN ('global', 'team', 'personal')),
|
||||
owner_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
|
||||
api_key_enc BLOB,
|
||||
key_nonce BLOB,
|
||||
key_scope TEXT NOT NULL DEFAULT 'global',
|
||||
|
||||
model_default TEXT,
|
||||
config TEXT DEFAULT '{}',
|
||||
headers TEXT DEFAULT '{}',
|
||||
settings TEXT DEFAULT '{}',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_private INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('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 = 1;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS provider_configs_updated_at AFTER UPDATE ON provider_configs
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE provider_configs SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 6. MODEL CATALOG
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_catalog (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_config_id TEXT NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
model_type TEXT DEFAULT 'chat',
|
||||
capabilities TEXT NOT NULL DEFAULT '{}',
|
||||
pricing TEXT,
|
||||
visibility TEXT DEFAULT 'disabled'
|
||||
CHECK (visibility IN ('enabled', 'disabled', 'team')),
|
||||
last_synced_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('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';
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_type ON model_catalog(model_type);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS model_catalog_updated_at AFTER UPDATE ON model_catalog
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE model_catalog SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 7. PERSONAS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS personas (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '',
|
||||
avatar TEXT DEFAULT '',
|
||||
|
||||
base_model_id TEXT NOT NULL,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
|
||||
system_prompt TEXT DEFAULT '',
|
||||
temperature REAL,
|
||||
max_tokens INTEGER,
|
||||
thinking_budget INTEGER,
|
||||
top_p REAL,
|
||||
|
||||
scope TEXT NOT NULL
|
||||
CHECK (scope IN ('global', 'team', 'personal')),
|
||||
owner_id TEXT,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_shared INTEGER DEFAULT 0,
|
||||
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('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 = 1;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS personas_updated_at AFTER UPDATE ON personas
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE personas SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 8. PERSONA GRANTS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
persona_id TEXT NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||
grant_type TEXT NOT NULL,
|
||||
grant_ref TEXT NOT NULL,
|
||||
config TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('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);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 9. RESOURCE GRANTS
|
||||
-- =========================================
|
||||
-- granted_groups stored as JSON text array: '["uuid1","uuid2"]'
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
resource_type TEXT NOT NULL
|
||||
CHECK (resource_type IN ('persona', 'knowledge_base')),
|
||||
resource_id TEXT NOT NULL,
|
||||
grant_scope TEXT NOT NULL DEFAULT 'team_only'
|
||||
CHECK (grant_scope IN ('team_only', 'global', 'groups')),
|
||||
granted_groups TEXT NOT NULL DEFAULT '[]',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
|
||||
UNIQUE(resource_type, resource_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_resource
|
||||
ON resource_grants(resource_type, resource_id);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS resource_grants_updated_at AFTER UPDATE ON resource_grants
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE resource_grants SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 10. PLATFORM POLICIES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_policies (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_by TEXT REFERENCES users(id),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT OR IGNORE 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');
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 11. GLOBAL SETTINGS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
updated_by TEXT REFERENCES users(id)
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO global_settings (key, value) VALUES
|
||||
('registration', '{"enabled": true}'),
|
||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'),
|
||||
('banner', '{"enabled": false, "text": "", "position": "both", "bg": "#007a33", "fg": "#ffffff"}'),
|
||||
('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"}}'),
|
||||
('model_roles', '{"utility": {"primary": null, "fallback": null}, "embedding": {"primary": null, "fallback": null}, "generation": {"primary": null, "fallback": null}}');
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 12. USER MODEL SETTINGS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_model_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
hidden INTEGER DEFAULT 0,
|
||||
preferred_temperature REAL,
|
||||
preferred_max_tokens INTEGER,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, model_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS user_model_settings_updated_at AFTER UPDATE ON user_model_settings
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE user_model_settings SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 13. CHANNELS
|
||||
-- =========================================
|
||||
-- tags stored as JSON text array: '["tag1","tag2"]'
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT DEFAULT 'direct'
|
||||
CHECK (type IN ('direct', 'group', 'channel')),
|
||||
model TEXT,
|
||||
system_prompt TEXT,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
is_archived INTEGER DEFAULT 0,
|
||||
is_pinned INTEGER DEFAULT 0,
|
||||
folder_id TEXT,
|
||||
folder TEXT,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||
settings TEXT DEFAULT '{}',
|
||||
tags TEXT DEFAULT '[]',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('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 TRIGGER IF NOT EXISTS channels_updated_at AFTER UPDATE ON channels
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE channels SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 14. MESSAGES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL
|
||||
CHECK (role IN ('user', 'assistant', 'system', 'tool')),
|
||||
content TEXT NOT NULL,
|
||||
model TEXT,
|
||||
tokens_used INTEGER,
|
||||
tool_calls TEXT,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
parent_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
sibling_index INTEGER DEFAULT 0,
|
||||
participant_type TEXT DEFAULT 'user',
|
||||
participant_id TEXT,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('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;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 15. CHANNEL MEMBERS & MODELS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT DEFAULT 'member',
|
||||
joined_at TEXT DEFAULT (datetime('now')),
|
||||
last_read_at TEXT DEFAULT (datetime('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 TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
display_name TEXT,
|
||||
system_prompt TEXT,
|
||||
settings TEXT DEFAULT '{}',
|
||||
is_default INTEGER DEFAULT 0,
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(channel_id, model_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 16. CHANNEL CURSORS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_cursors (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
active_leaf_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
updated_at TEXT DEFAULT (datetime('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);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 17. FOLDERS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('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);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS folders_updated_at AFTER UPDATE ON folders
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE folders SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 18. NOTES
|
||||
-- =========================================
|
||||
-- search_vector and embedding columns omitted (Postgres-only features).
|
||||
-- Full-text search uses LIKE fallback. Semantic search feature-gated.
|
||||
-- tags stored as JSON text array.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT DEFAULT '',
|
||||
folder_path TEXT DEFAULT '/',
|
||||
tags TEXT DEFAULT '[]',
|
||||
metadata TEXT DEFAULT '{}',
|
||||
source_channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_folder ON notes(user_id, folder_path);
|
||||
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 TRIGGER IF NOT EXISTS notes_updated_at AFTER UPDATE ON notes
|
||||
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE notes SET updated_at = datetime('now') WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 19. AUDIT LOG
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
ip_address TEXT,
|
||||
user_agent TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('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);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 20. USAGE TRACKING
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
provider_scope TEXT NOT NULL DEFAULT 'global',
|
||||
model_id TEXT NOT NULL,
|
||||
role TEXT,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cost_input REAL,
|
||||
cost_output REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_user ON usage_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_created ON usage_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_provider ON usage_log(provider_config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_model ON usage_log(model_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_log_scope ON usage_log(provider_scope);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 21. MODEL PRICING
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_config_id TEXT NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
input_per_m REAL,
|
||||
output_per_m REAL,
|
||||
cache_create_per_m REAL,
|
||||
cache_read_per_m REAL,
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
updated_by TEXT REFERENCES users(id),
|
||||
UNIQUE(provider_config_id, model_id)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 22. EXTENSIONS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
id TEXT PRIMARY KEY,
|
||||
ext_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '0.0.0',
|
||||
tier TEXT NOT NULL DEFAULT 'browser',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
manifest TEXT NOT NULL DEFAULT '{}',
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
scope TEXT NOT NULL DEFAULT 'global',
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
installed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled)
|
||||
WHERE is_enabled = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extension_user_settings (
|
||||
extension_id TEXT NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
settings TEXT NOT NULL DEFAULT '{}',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (extension_id, user_id)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 23. ATTACHMENTS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
extracted_text TEXT,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_channel ON attachments(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_user_size ON attachments(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id)
|
||||
WHERE message_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at)
|
||||
WHERE message_id IS NULL;
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 24. KNOWLEDGE BASES
|
||||
-- =========================================
|
||||
-- Vector columns: kb_chunks.embedding stored as JSON TEXT for app-level cosine similarity.
|
||||
-- search_vector columns omitted (Postgres-only tsvector feature).
|
||||
-- KB ingestion works (stores text chunks) but similarity search is
|
||||
-- feature-gated: requires sqlite-vec extension or returns graceful error.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'global',
|
||||
owner_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
embedding_config TEXT NOT NULL DEFAULT '{}',
|
||||
document_count INTEGER NOT NULL DEFAULT 0,
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
discoverable INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
CONSTRAINT kb_scope_check CHECK (
|
||||
(scope = 'global' AND owner_id IS NULL) OR
|
||||
(scope = 'team' AND team_id IS NOT NULL) OR
|
||||
(scope = 'personal' AND owner_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_scope ON knowledge_bases(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_owner ON knowledge_bases(owner_id) WHERE owner_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_team ON knowledge_bases(team_id) WHERE team_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
extracted_text TEXT,
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT,
|
||||
uploaded_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbdoc_kb ON kb_documents(kb_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
document_id TEXT NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
token_count INTEGER NOT NULL DEFAULT 0,
|
||||
embedding TEXT, -- JSON array of float64 for app-level cosine similarity
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_kb ON kb_chunks(kb_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kbchunk_doc ON kb_chunks(document_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_knowledge_bases (
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (channel_id, kb_id)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- 25. PERSONA-KB BINDING (v0.17.0)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persona_knowledge_bases (
|
||||
persona_id TEXT NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
auto_search INTEGER NOT NULL DEFAULT 0,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (persona_id, kb_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_kb_persona ON persona_knowledge_bases(persona_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_persona_kb_kb ON persona_knowledge_bases(kb_id);
|
||||
|
||||
-- v0.17.0 policy
|
||||
INSERT OR IGNORE INTO platform_policies (key, value) VALUES
|
||||
('kb_direct_access', 'true');
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/lib/pq"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TestDB holds a connection to the test database.
|
||||
@@ -17,9 +19,9 @@ var TestDB *sql.DB
|
||||
|
||||
const testDBName = "chat_switchboard_ci"
|
||||
|
||||
// SetupTestDB connects to PostgreSQL, creates the CI test database (or
|
||||
// connects to an existing one created by CI bootstrap), runs all
|
||||
// migrations, and sets database.DB so handlers can use it.
|
||||
// SetupTestDB connects to the appropriate database backend (Postgres or
|
||||
// SQLite based on DB_DRIVER env), runs migrations, and sets database.DB
|
||||
// so handlers can use it.
|
||||
//
|
||||
// Call in TestMain:
|
||||
//
|
||||
@@ -30,11 +32,78 @@ const testDBName = "chat_switchboard_ci"
|
||||
// os.Exit(code)
|
||||
// }
|
||||
//
|
||||
// Requires env: TEST_DATABASE_URL (full DSN to maintenance DB, e.g. postgres)
|
||||
// OR individual: PGHOST, PGPORT, PGUSER, PGPASSWORD
|
||||
// For Postgres: requires PGHOST+PGUSER or TEST_DATABASE_URL.
|
||||
// For SQLite: set DB_DRIVER=sqlite (uses temp file, no external deps).
|
||||
//
|
||||
// Returns a cleanup function that drops the test database.
|
||||
// Returns a cleanup function.
|
||||
func SetupTestDB() func() {
|
||||
driver := os.Getenv("DB_DRIVER")
|
||||
if driver == "sqlite" {
|
||||
return setupSQLiteTestDB()
|
||||
}
|
||||
return setupPostgresTestDB()
|
||||
}
|
||||
|
||||
// ── SQLite test setup ───────────────────────
|
||||
|
||||
func setupSQLiteTestDB() func() {
|
||||
CurrentDialect = DialectSQLite
|
||||
|
||||
// Use a temp file so multiple connections share the same DB.
|
||||
tmpFile, err := os.CreateTemp("", "switchboard-test-*.db")
|
||||
if err != nil {
|
||||
log.Printf("⚠ Cannot create temp SQLite DB: %v — skipping DB tests", err)
|
||||
return func() {}
|
||||
}
|
||||
dbPath := tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
|
||||
DB, err = sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
log.Printf("⚠ Cannot open SQLite DB: %v — skipping DB tests", err)
|
||||
os.Remove(dbPath)
|
||||
return func() {}
|
||||
}
|
||||
if err := DB.Ping(); err != nil {
|
||||
DB.Close()
|
||||
DB = nil
|
||||
log.Printf("⚠ Cannot ping SQLite DB: %v — skipping DB tests", err)
|
||||
os.Remove(dbPath)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// SQLite: single writer
|
||||
DB.SetMaxOpenConns(1)
|
||||
|
||||
TestDB = DB
|
||||
|
||||
// Run SQLite migrations
|
||||
if err := Migrate(); err != nil {
|
||||
DB.Close()
|
||||
DB = nil
|
||||
TestDB = nil
|
||||
os.Remove(dbPath)
|
||||
log.Fatalf("❌ SQLite migration failed on test DB: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✓ SQLite test database ready: %s", dbPath)
|
||||
|
||||
return func() {
|
||||
if DB != nil {
|
||||
DB.Close()
|
||||
DB = nil
|
||||
TestDB = nil
|
||||
}
|
||||
os.Remove(dbPath)
|
||||
log.Printf("✓ Removed SQLite test database: %s", dbPath)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Postgres test setup (original) ──────────
|
||||
|
||||
func setupPostgresTestDB() func() {
|
||||
CurrentDialect = DialectPostgres
|
||||
|
||||
mainDSN := os.Getenv("TEST_DATABASE_URL")
|
||||
host := envOr("PGHOST", "")
|
||||
port := envOr("PGPORT", "5432")
|
||||
@@ -50,7 +119,6 @@ func SetupTestDB() func() {
|
||||
host, port, user, pass)
|
||||
}
|
||||
|
||||
// Try to connect to admin/maintenance DB for create/drop operations
|
||||
adminDB, err := sql.Open("postgres", mainDSN)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Cannot connect to admin DB: %v — skipping DB tests", err)
|
||||
@@ -62,15 +130,10 @@ func SetupTestDB() func() {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// Check if test DB already exists (e.g. created by CI bootstrap with
|
||||
// admin privileges and extensions like pgvector already installed).
|
||||
// Only create if it doesn't exist — never drop, because recreating
|
||||
// as the app user would lose extensions that require superuser.
|
||||
createdByUs := false
|
||||
var dbExists bool
|
||||
err = adminDB.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", testDBName).Scan(&dbExists)
|
||||
if err != nil {
|
||||
// DB doesn't exist — create it
|
||||
if _, err := adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s", testDBName)); err != nil {
|
||||
log.Printf("⚠ Cannot CREATE DATABASE %s (will try to connect to existing): %v", testDBName, err)
|
||||
} else {
|
||||
@@ -81,7 +144,6 @@ func SetupTestDB() func() {
|
||||
log.Printf("✓ Test database %s already exists (keeping extensions)", testDBName)
|
||||
}
|
||||
|
||||
// Build DSN for the test DB
|
||||
var testDSN string
|
||||
if host != "" {
|
||||
testDSN = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
@@ -90,7 +152,6 @@ func SetupTestDB() func() {
|
||||
testDSN = replaceDBName(mainDSN, testDBName)
|
||||
}
|
||||
|
||||
// Connect to test DB
|
||||
DB, err = sql.Open("postgres", testDSN)
|
||||
if err != nil {
|
||||
adminDB.Close()
|
||||
@@ -106,7 +167,6 @@ func SetupTestDB() func() {
|
||||
}
|
||||
TestDB = DB
|
||||
|
||||
// Ensure required extensions (may already exist from CI bootstrap)
|
||||
for _, ext := range []string{"uuid-ossp", "pgcrypto", "vector"} {
|
||||
DB.Exec(fmt.Sprintf(`CREATE EXTENSION IF NOT EXISTS "%s"`, ext))
|
||||
}
|
||||
@@ -121,7 +181,6 @@ func SetupTestDB() func() {
|
||||
END $$;
|
||||
`)
|
||||
|
||||
// Run all migrations
|
||||
if err := Migrate(); err != nil {
|
||||
DB.Close()
|
||||
DB = nil
|
||||
@@ -133,7 +192,6 @@ func SetupTestDB() func() {
|
||||
log.Fatalf("❌ Migration failed on test DB: %v", err)
|
||||
}
|
||||
|
||||
// Return cleanup
|
||||
return func() {
|
||||
if DB != nil {
|
||||
DB.Close()
|
||||
@@ -148,22 +206,52 @@ func SetupTestDB() func() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialect helpers ─────────────────────────
|
||||
|
||||
// PH returns a placeholder for the Nth parameter (1-indexed).
|
||||
// Postgres: $1, $2, ... SQLite: ?, ?, ...
|
||||
// Exported for use by test files in other packages (e.g. handlers).
|
||||
func PH(n int) string {
|
||||
if IsSQLite() {
|
||||
return "?"
|
||||
}
|
||||
return fmt.Sprintf("$%d", n)
|
||||
}
|
||||
|
||||
// placeholders returns "?, ?, ?" or "$1, $2, $3" for n params.
|
||||
func placeholders(n int) string {
|
||||
parts := make([]string, n)
|
||||
for i := range parts {
|
||||
parts[i] = PH(i + 1)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// nowSQL returns the SQL expression for "current timestamp".
|
||||
func nowSQL() string {
|
||||
if IsSQLite() {
|
||||
return "datetime('now')"
|
||||
}
|
||||
return "NOW()"
|
||||
}
|
||||
|
||||
// ── Shared test helpers ─────────────────────
|
||||
|
||||
// RequireTestDB skips a test if no test database is available.
|
||||
func RequireTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
if DB == nil || TestDB == nil {
|
||||
t.Skip("requires TEST_DATABASE_URL or PGHOST+PGUSER environment variables")
|
||||
t.Skip("requires TEST_DATABASE_URL or PGHOST+PGUSER environment variables (or DB_DRIVER=sqlite)")
|
||||
}
|
||||
}
|
||||
|
||||
// TruncateAll truncates all application tables for test isolation.
|
||||
// Faster than recreating the DB for each test.
|
||||
func TruncateAll(t *testing.T) {
|
||||
t.Helper()
|
||||
if DB == nil {
|
||||
return
|
||||
}
|
||||
// Order matters due to foreign keys — truncate with CASCADE
|
||||
|
||||
tables := []string{
|
||||
"resource_grants",
|
||||
"group_members",
|
||||
@@ -177,6 +265,7 @@ func TruncateAll(t *testing.T) {
|
||||
"channel_models",
|
||||
"channel_members",
|
||||
"channel_knowledge_bases",
|
||||
"persona_knowledge_bases",
|
||||
"kb_chunks",
|
||||
"kb_documents",
|
||||
"knowledge_bases",
|
||||
@@ -192,44 +281,87 @@ func TruncateAll(t *testing.T) {
|
||||
"users",
|
||||
"platform_policies",
|
||||
"global_config",
|
||||
}
|
||||
for _, table := range tables {
|
||||
DB.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
|
||||
"global_settings",
|
||||
"folders",
|
||||
"attachments",
|
||||
"extension_user_settings",
|
||||
"extensions",
|
||||
}
|
||||
|
||||
// Re-seed global_settings — CASCADE from users wipes rows with updated_by FK.
|
||||
// Re-run the seed SQL from migrations 001 + 004.
|
||||
DB.Exec(`
|
||||
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', '{}'::jsonb),
|
||||
('model_roles', '{
|
||||
"utility": { "primary": null, "fallback": null },
|
||||
"embedding": { "primary": null, "fallback": null },
|
||||
"generation": { "primary": null, "fallback": null }
|
||||
}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
if IsSQLite() {
|
||||
DB.Exec("PRAGMA foreign_keys = OFF")
|
||||
for _, table := range tables {
|
||||
DB.Exec(fmt.Sprintf("DELETE FROM %s", table))
|
||||
}
|
||||
DB.Exec("PRAGMA foreign_keys = ON")
|
||||
} else {
|
||||
for _, table := range tables {
|
||||
DB.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
|
||||
}
|
||||
}
|
||||
|
||||
// Re-seed platform_policies — also wiped by CASCADE from users truncation
|
||||
// (platform_policies.updated_by REFERENCES users(id)).
|
||||
DB.Exec(`
|
||||
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
|
||||
`)
|
||||
// Re-seed default config rows.
|
||||
if IsSQLite() {
|
||||
DB.Exec(`
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('registration', '{"enabled": true}'),
|
||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'),
|
||||
('banner', '{"enabled": false, "text": "", "position": "both", "bg": "#007a33", "fg": "#ffffff"}'),
|
||||
('banner_presets', '{}'),
|
||||
('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
DB.Exec(`
|
||||
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
|
||||
`)
|
||||
} else {
|
||||
DB.Exec(`
|
||||
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', '{}'::jsonb),
|
||||
('model_roles', '{
|
||||
"utility": { "primary": null, "fallback": null },
|
||||
"embedding": { "primary": null, "fallback": null },
|
||||
"generation": { "primary": null, "fallback": null }
|
||||
}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
DB.Exec(`
|
||||
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
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
// SeedTestUser creates a test user and returns the user ID.
|
||||
func SeedTestUser(t *testing.T, username, email string) string {
|
||||
t.Helper()
|
||||
if IsSQLite() {
|
||||
id := uuid.New().String()
|
||||
_, err := DB.Exec(`
|
||||
INSERT INTO users (id, username, email, password_hash, role)
|
||||
VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user')
|
||||
`, id, username, email)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestUser: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
var id string
|
||||
err := DB.QueryRow(`
|
||||
INSERT INTO users (username, email, password_hash, role)
|
||||
@@ -245,29 +377,35 @@ func SeedTestUser(t *testing.T, username, email string) string {
|
||||
// SeedTestChannel creates a test channel owned by userID and returns the channel ID.
|
||||
func SeedTestChannel(t *testing.T, userID, title string) string {
|
||||
t.Helper()
|
||||
if IsSQLite() {
|
||||
id := uuid.New().String()
|
||||
_, err := DB.Exec(`INSERT INTO channels (id, user_id, title) VALUES (?, ?, ?)`, id, userID, title)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestChannel: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
var id string
|
||||
err := DB.QueryRow(`
|
||||
INSERT INTO channels (user_id, title)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id
|
||||
`, userID, title).Scan(&id)
|
||||
err := DB.QueryRow(`INSERT INTO channels (user_id, title) VALUES ($1, $2) RETURNING id`, userID, title).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestChannel: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// SeedTestTeam creates a test team and returns the team ID.
|
||||
func SeedTestTeam(t *testing.T, name, createdBy string) string {
|
||||
t.Helper()
|
||||
if IsSQLite() {
|
||||
id := uuid.New().String()
|
||||
_, err := DB.Exec(`INSERT INTO teams (id, name, created_by) VALUES (?, ?, ?)`, id, name, createdBy)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestTeam: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
var id string
|
||||
err := DB.QueryRow(`
|
||||
INSERT INTO teams (name, created_by)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id
|
||||
`, name, createdBy).Scan(&id)
|
||||
err := DB.QueryRow(`INSERT INTO teams (name, created_by) VALUES ($1, $2) RETURNING id`, name, createdBy).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestTeam: %v", err)
|
||||
}
|
||||
@@ -277,10 +415,19 @@ func SeedTestTeam(t *testing.T, name, createdBy string) string {
|
||||
// SeedTestTeamMember adds a user to a team.
|
||||
func SeedTestTeamMember(t *testing.T, teamID, userID, role string) {
|
||||
t.Helper()
|
||||
_, err := DB.Exec(`
|
||||
if IsSQLite() {
|
||||
_, err := DB.Exec(`INSERT INTO team_members (id, team_id, user_id, role) VALUES (?, ?, ?, ?)`,
|
||||
uuid.New().String(), teamID, userID, role)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestTeamMember: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
q := fmt.Sprintf(`
|
||||
INSERT INTO team_members (team_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
`, teamID, userID, role)
|
||||
VALUES (%s, %s, %s)
|
||||
`, PH(1), PH(2), PH(3))
|
||||
_, err := DB.Exec(q, teamID, userID, role)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestTeamMember: %v", err)
|
||||
}
|
||||
@@ -289,16 +436,22 @@ func SeedTestTeamMember(t *testing.T, teamID, userID, role string) {
|
||||
// SeedTestGroup creates a test group and returns the group ID.
|
||||
func SeedTestGroup(t *testing.T, name, scope string, teamID *string, createdBy string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
var teamArg interface{}
|
||||
if teamID != nil {
|
||||
teamArg = *teamID
|
||||
}
|
||||
err := DB.QueryRow(`
|
||||
INSERT INTO groups (name, scope, team_id, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id
|
||||
`, name, scope, teamArg, createdBy).Scan(&id)
|
||||
if IsSQLite() {
|
||||
id := uuid.New().String()
|
||||
_, err := DB.Exec(`INSERT INTO groups (id, name, scope, team_id, created_by) VALUES (?, ?, ?, ?, ?)`,
|
||||
id, name, scope, teamArg, createdBy)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestGroup: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
var id string
|
||||
err := DB.QueryRow(`INSERT INTO groups (name, scope, team_id, created_by) VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
name, scope, teamArg, createdBy).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestGroup: %v", err)
|
||||
}
|
||||
@@ -308,15 +461,26 @@ func SeedTestGroup(t *testing.T, name, scope string, teamID *string, createdBy s
|
||||
// SeedGroupMember adds a user to a group.
|
||||
func SeedGroupMember(t *testing.T, groupID, userID, addedBy string) {
|
||||
t.Helper()
|
||||
_, err := DB.Exec(`
|
||||
if IsSQLite() {
|
||||
_, err := DB.Exec(`INSERT INTO group_members (id, group_id, user_id, added_by) VALUES (?, ?, ?, ?)`,
|
||||
uuid.New().String(), groupID, userID, addedBy)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedGroupMember: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
q := fmt.Sprintf(`
|
||||
INSERT INTO group_members (group_id, user_id, added_by)
|
||||
VALUES ($1, $2, $3)
|
||||
`, groupID, userID, addedBy)
|
||||
VALUES (%s, %s, %s)
|
||||
`, PH(1), PH(2), PH(3))
|
||||
_, err := DB.Exec(q, groupID, userID, addedBy)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedGroupMember: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
@@ -324,9 +488,7 @@ func envOr(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// replaceDBName swaps the dbname in a DSN string.
|
||||
func replaceDBName(dsn, newDB string) string {
|
||||
// Handle key=value format
|
||||
if strings.Contains(dsn, "dbname=") {
|
||||
parts := strings.Fields(dsn)
|
||||
for i, p := range parts {
|
||||
@@ -336,7 +498,6 @@ func replaceDBName(dsn, newDB string) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle URL format: postgres://user:pass@host/olddb?...
|
||||
if strings.Contains(dsn, "://") {
|
||||
idx := strings.LastIndex(dsn, "/")
|
||||
qIdx := strings.Index(dsn, "?")
|
||||
@@ -345,6 +506,5 @@ func replaceDBName(dsn, newDB string) string {
|
||||
}
|
||||
return dsn[:idx+1] + newDB
|
||||
}
|
||||
// Fallback: append dbname
|
||||
return dsn + " dbname=" + newDB
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ go 1.22
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/minio/minio-go/v7 v7.0.82
|
||||
golang.org/x/crypto v0.14.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -162,7 +162,7 @@ func (h *AttachmentHandler) Upload(c *gin.Context) {
|
||||
|
||||
// Update storage_key in PG
|
||||
database.DB.ExecContext(c.Request.Context(),
|
||||
`UPDATE attachments SET storage_key = $1 WHERE id = $2`,
|
||||
database.Q(`UPDATE attachments SET storage_key = $1 WHERE id = $2`),
|
||||
att.StorageKey, att.ID)
|
||||
|
||||
// For images, mark extraction as not needed (complete immediately)
|
||||
@@ -421,7 +421,7 @@ func (h *AttachmentHandler) CleanupChannelStorage(channelID string) {
|
||||
func (h *AttachmentHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool {
|
||||
var ownerID string
|
||||
err := database.DB.QueryRowContext(c.Request.Context(),
|
||||
`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID)
|
||||
database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return false
|
||||
|
||||
@@ -3,10 +3,11 @@ package handlers
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
@@ -33,10 +34,17 @@ func AuditLog(c *gin.Context, action, resourceType, resourceID string, metadata
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO audit_log (actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
|
||||
`, actorID, action, resourceType, resourceID, metaJSON, ip, ua)
|
||||
if database.IsSQLite() {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO audit_log (id, actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, uuid.New().String(), actorID, action, resourceType, resourceID, metaJSON, ip, ua)
|
||||
} else {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO audit_log (actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
|
||||
`, actorID, action, resourceType, resourceID, metaJSON, ip, ua)
|
||||
}
|
||||
}
|
||||
|
||||
// AuditLogAnon inserts an audit entry without a gin context (e.g. system actions).
|
||||
@@ -50,10 +58,18 @@ func AuditLogAnon(action, resourceType, resourceID string, metadata map[string]i
|
||||
metaJSON = string(b)
|
||||
}
|
||||
}
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO audit_log (action, resource_type, resource_id, metadata)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
`, action, resourceType, resourceID, metaJSON)
|
||||
|
||||
if database.IsSQLite() {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO audit_log (id, action, resource_type, resource_id, metadata)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, uuid.New().String(), action, resourceType, resourceID, metaJSON)
|
||||
} else {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO audit_log (action, resource_type, resource_id, metadata)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
`, action, resourceType, resourceID, metaJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// AuditLogWithActor inserts an audit entry with an explicit actor ID (e.g. pre-auth flows).
|
||||
@@ -69,10 +85,18 @@ func AuditLogWithActor(actorID string, c *gin.Context, action, resourceType, res
|
||||
metaJSON = string(b)
|
||||
}
|
||||
}
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO audit_log (actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
|
||||
`, actorID, action, resourceType, resourceID, metaJSON, ip, ua)
|
||||
|
||||
if database.IsSQLite() {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO audit_log (id, actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, uuid.New().String(), actorID, action, resourceType, resourceID, metaJSON, ip, ua)
|
||||
} else {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO audit_log (actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
|
||||
`, actorID, action, resourceType, resourceID, metaJSON, ip, ua)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin Audit Viewer ──────────────────────
|
||||
@@ -94,49 +118,52 @@ type auditEntry struct {
|
||||
func (h *AdminHandler) ListAuditLog(c *gin.Context) {
|
||||
page, perPage, offset := parsePagination(c)
|
||||
|
||||
// Build filter clauses
|
||||
// Build filter clauses with ? placeholders, convert for Postgres later
|
||||
where := "WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
argN := 1
|
||||
|
||||
if action := c.Query("action"); action != "" {
|
||||
where += " AND al.action = $" + strconv.Itoa(argN)
|
||||
where += " AND al.action = ?"
|
||||
args = append(args, action)
|
||||
argN++
|
||||
}
|
||||
if actorID := c.Query("actor_id"); actorID != "" {
|
||||
where += " AND al.actor_id = $" + strconv.Itoa(argN)
|
||||
where += " AND al.actor_id = ?"
|
||||
args = append(args, actorID)
|
||||
argN++
|
||||
}
|
||||
if rt := c.Query("resource_type"); rt != "" {
|
||||
where += " AND al.resource_type = $" + strconv.Itoa(argN)
|
||||
where += " AND al.resource_type = ?"
|
||||
args = append(args, rt)
|
||||
argN++
|
||||
}
|
||||
|
||||
// Count
|
||||
var total int
|
||||
countArgs := make([]interface{}, len(args))
|
||||
copy(countArgs, args)
|
||||
err := database.DB.QueryRow(`SELECT COUNT(*) FROM audit_log al `+where, countArgs...).Scan(&total)
|
||||
countQ := convertPlaceholders(`SELECT COUNT(*) FROM audit_log al ` + where)
|
||||
err := database.DB.QueryRow(countQ, countArgs...).Scan(&total)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Query
|
||||
query := `
|
||||
// Query — metadata column needs ::text on Postgres only
|
||||
metadataCol := "COALESCE(al.metadata::text, '{}')"
|
||||
if database.IsSQLite() {
|
||||
metadataCol = "COALESCE(al.metadata, '{}')"
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name,
|
||||
al.action, al.resource_type, al.resource_id,
|
||||
COALESCE(al.metadata::text, '{}'), al.ip_address, al.created_at
|
||||
%s, al.ip_address, al.created_at
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.actor_id = u.id
|
||||
` + where + `
|
||||
%s
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
|
||||
LIMIT ? OFFSET ?`, metadataCol, where)
|
||||
args = append(args, perPage, offset)
|
||||
|
||||
query = convertPlaceholders(query)
|
||||
rows, err := database.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
|
||||
@@ -266,18 +266,18 @@ func hashToken(token string) string {
|
||||
//
|
||||
// Does NOT evict from UEK cache or write audit logs — callers handle that.
|
||||
func DestroyVaultDB(ctx context.Context, userID string) (providersDeleted int64) {
|
||||
_, err := database.DB.ExecContext(ctx, `
|
||||
_, err := database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE users
|
||||
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
`), userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ DestroyVaultDB: failed to clear vault columns for user %s: %v", userID, err)
|
||||
}
|
||||
|
||||
result, err := database.DB.ExecContext(ctx, `
|
||||
result, err := database.DB.ExecContext(ctx, database.Q(`
|
||||
DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1
|
||||
`, userID)
|
||||
`), userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ DestroyVaultDB: failed to delete personal providers for user %s: %v", userID, err)
|
||||
return 0
|
||||
@@ -298,10 +298,10 @@ func ProbeAndRepairVault(ctx context.Context, userID, password string) {
|
||||
var vaultSet bool
|
||||
var encryptedUEK, salt, nonce []byte
|
||||
|
||||
err := database.DB.QueryRowContext(ctx, `
|
||||
err := database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce)
|
||||
`), userID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce)
|
||||
if err != nil || !vaultSet {
|
||||
return // no vault to probe
|
||||
}
|
||||
@@ -344,11 +344,11 @@ func (h *AuthHandler) initVault(ctx context.Context, userID, password string) er
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = database.DB.ExecContext(ctx, `
|
||||
_, err = database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE users
|
||||
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, vault_set = true
|
||||
WHERE id = $4
|
||||
`, encryptedUEK, salt, nonce, userID)
|
||||
`), encryptedUEK, salt, nonce, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -369,10 +369,10 @@ func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, passwo
|
||||
var vaultSet bool
|
||||
var encryptedUEK, salt, nonce []byte
|
||||
|
||||
err := database.DB.QueryRowContext(ctx, `
|
||||
err := database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
|
||||
FROM users WHERE id = $1
|
||||
`, user.ID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce)
|
||||
`), user.ID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Vault query failed for user %s: %v", user.ID, err)
|
||||
return
|
||||
|
||||
@@ -77,7 +77,7 @@ func (h *SettingsHandler) UploadAvatar(c *gin.Context) {
|
||||
|
||||
// Store in DB
|
||||
_, err = database.DB.Exec(
|
||||
`UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2`,
|
||||
database.Q(`UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2`),
|
||||
dataURI, userID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -94,7 +94,7 @@ func (h *SettingsHandler) DeleteAvatar(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
_, err := database.DB.Exec(
|
||||
`UPDATE users SET avatar_url = NULL, updated_at = NOW() WHERE id = $1`,
|
||||
database.Q(`UPDATE users SET avatar_url = NULL, updated_at = NOW() WHERE id = $1`),
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -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 personas SET avatar = $1, updated_at = NOW() WHERE id = $2`,
|
||||
database.Q(`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 personas SET avatar = '', updated_at = NOW() WHERE id = $1`,
|
||||
database.Q(`UPDATE personas SET avatar = '', updated_at = NOW() WHERE id = $1`),
|
||||
presetID,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -42,14 +42,6 @@ func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ResolveModelCaps is the canonical capability resolver for any model.
|
||||
// Priority chain:
|
||||
// 1. model_catalog DB — exact match (model_id + provider_config_id)
|
||||
// 2. model_catalog DB — any provider (same model, different config)
|
||||
// 3. Heuristic inference (name-based fallback)
|
||||
//
|
||||
// No hardcoded model table — the same model can have different capabilities
|
||||
// depending on the provider hosting it. The catalog is populated by provider
|
||||
// API sync (auto-fetch on create, manual refresh).
|
||||
func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapabilities {
|
||||
// 1. Exact match: model_id + provider_config_id
|
||||
if configID != "" {
|
||||
@@ -82,15 +74,15 @@ func capsFromCatalog(modelID, configID string) (models.ModelCapabilities, bool)
|
||||
var capsJSON []byte
|
||||
var err error
|
||||
if configID != "" {
|
||||
err = database.DB.QueryRow(`
|
||||
err = database.DB.QueryRow(database.Q(`
|
||||
SELECT capabilities FROM model_catalog
|
||||
WHERE model_id = $1 AND provider_config_id = $2
|
||||
`, modelID, configID).Scan(&capsJSON)
|
||||
`), modelID, configID).Scan(&capsJSON)
|
||||
} else {
|
||||
err = database.DB.QueryRow(`
|
||||
err = database.DB.QueryRow(database.Q(`
|
||||
SELECT capabilities FROM model_catalog
|
||||
WHERE model_id = $1 ORDER BY last_synced_at DESC NULLS LAST LIMIT 1
|
||||
`, modelID).Scan(&capsJSON)
|
||||
WHERE model_id = $1 ORDER BY last_synced_at DESC LIMIT 1
|
||||
`), modelID).Scan(&capsJSON)
|
||||
}
|
||||
if err != nil || len(capsJSON) == 0 {
|
||||
return models.ModelCapabilities{}, false
|
||||
@@ -115,10 +107,10 @@ func liveQueryModelCaps(c *gin.Context, configID, modelID string) (models.ModelC
|
||||
var providerID, endpoint string
|
||||
var apiKey *string
|
||||
var headersJSON []byte
|
||||
err := database.DB.QueryRow(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT provider, endpoint, api_key_enc, headers
|
||||
FROM provider_configs WHERE id = $1 AND is_active = true
|
||||
`, configID).Scan(&providerID, &endpoint, &apiKey, &headersJSON)
|
||||
`), configID).Scan(&providerID, &endpoint, &apiKey, &headersJSON)
|
||||
if err != nil {
|
||||
return models.ModelCapabilities{}, false
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/lib/pq"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Request / Response types ────────────────
|
||||
@@ -85,6 +86,73 @@ func SetChannelDeleteHook(fn func(channelID string)) {
|
||||
channelDeleteHook = fn
|
||||
}
|
||||
|
||||
// ── Tag Helpers ─────────────────────────────
|
||||
|
||||
// writeTagsArg returns a value suitable for inserting/updating the tags column.
|
||||
// Postgres: pq.Array SQLite: JSON string
|
||||
func writeTagsArg(tags []string) interface{} {
|
||||
if database.IsSQLite() {
|
||||
b, _ := json.Marshal(tags)
|
||||
return string(b)
|
||||
}
|
||||
return pq.Array(tags)
|
||||
}
|
||||
|
||||
// ── JSON scanner (settings column) ──────────
|
||||
// SQLite returns TEXT for JSON columns; json.RawMessage ([]byte) can't scan
|
||||
// from a string with modernc.org/sqlite. This wrapper handles both dialects.
|
||||
|
||||
type jsonScanner struct{ dest *json.RawMessage }
|
||||
|
||||
func scanJSON(dest *json.RawMessage) *jsonScanner { return &jsonScanner{dest: dest} }
|
||||
|
||||
func (s *jsonScanner) Scan(src interface{}) error {
|
||||
switch v := src.(type) {
|
||||
case []byte:
|
||||
*s.dest = json.RawMessage(v)
|
||||
case string:
|
||||
*s.dest = json.RawMessage(v)
|
||||
case nil:
|
||||
*s.dest = json.RawMessage("{}")
|
||||
default:
|
||||
*s.dest = json.RawMessage("{}")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tagsScanner wraps a *[]string so rows.Scan can populate it on both dialects.
|
||||
type tagsScanner struct {
|
||||
dest *[]string
|
||||
}
|
||||
|
||||
func scanTags(dest *[]string) *tagsScanner {
|
||||
return &tagsScanner{dest: dest}
|
||||
}
|
||||
|
||||
func (s *tagsScanner) Scan(src interface{}) error {
|
||||
if database.IsSQLite() {
|
||||
var raw string
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
raw = v
|
||||
case []byte:
|
||||
raw = string(v)
|
||||
case nil:
|
||||
*s.dest = []string{}
|
||||
return nil
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
|
||||
*s.dest = []string{}
|
||||
return nil
|
||||
}
|
||||
*s.dest = arr
|
||||
return nil
|
||||
}
|
||||
// Postgres: delegate to pq
|
||||
return pq.Array(s.dest).Scan(src)
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// getUserID extracts the authenticated user's ID from context.
|
||||
@@ -146,7 +214,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
|
||||
if err := database.DB.QueryRow(database.Q(countQuery), countArgs...).Scan(&total); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"})
|
||||
return
|
||||
}
|
||||
@@ -185,7 +253,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
|
||||
args = append(args, perPage, offset)
|
||||
|
||||
rows, err := database.DB.Query(query, args...)
|
||||
rows, err := database.DB.Query(database.Q(query), args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
|
||||
return
|
||||
@@ -199,7 +267,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
err := rows.Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
pq.Array(&tags), &ch.Settings,
|
||||
scanTags(&tags), scanJSON(&ch.Settings),
|
||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -243,23 +311,54 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
channelType = "direct"
|
||||
}
|
||||
|
||||
// INSERT and retrieve the new row
|
||||
var ch channelResponse
|
||||
var tags []string
|
||||
err := database.DB.QueryRow(`
|
||||
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, provider_config_id, system_prompt,
|
||||
is_archived, is_pinned, folder, tags, settings, created_at, updated_at
|
||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
||||
req.Folder, pq.Array(req.Tags),
|
||||
).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
||||
return
|
||||
|
||||
if database.IsSQLite() {
|
||||
id := store.NewID()
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO channels (id, user_id, title, type, description, model,
|
||||
system_prompt, provider_config_id, folder, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, userID, req.Title, channelType, req.Description, req.Model,
|
||||
req.SystemPrompt, req.APIConfigID, req.Folder, writeTagsArg(req.Tags),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
||||
return
|
||||
}
|
||||
// Read back the row
|
||||
err = database.DB.QueryRow(`
|
||||
SELECT id, user_id, title, type, description, model, provider_config_id,
|
||||
system_prompt, is_archived, is_pinned, folder, tags, settings,
|
||||
created_at, updated_at
|
||||
FROM channels WHERE id = ?`, id).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created channel: " + err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
||||
is_archived, is_pinned, folder, tags, settings, created_at, updated_at
|
||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
||||
req.Folder, pq.Array(req.Tags),
|
||||
).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if tags == nil {
|
||||
@@ -269,19 +368,35 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
ch.MessageCount = 0
|
||||
|
||||
// Auto-create channel_member for the creator
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_members (channel_id, user_id, role)
|
||||
VALUES ($1, $2, 'owner')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, ch.ID, userID)
|
||||
if database.IsSQLite() {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_members (id, channel_id, user_id, role)
|
||||
VALUES (?, ?, ?, 'owner')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, store.NewID(), ch.ID, userID)
|
||||
} else {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_members (channel_id, user_id, role)
|
||||
VALUES ($1, $2, 'owner')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, ch.ID, userID)
|
||||
}
|
||||
|
||||
// Auto-create channel_model if model specified
|
||||
if req.Model != "" {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
|
||||
VALUES ($1, $2, $3, true)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, ch.ID, req.Model, req.APIConfigID)
|
||||
if database.IsSQLite() {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, is_default)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, store.NewID(), ch.ID, req.Model, req.APIConfigID)
|
||||
} else {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
|
||||
VALUES ($1, $2, $3, true)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, ch.ID, req.Model, req.APIConfigID)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, ch)
|
||||
@@ -295,7 +410,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
||||
|
||||
var ch channelResponse
|
||||
var tags []string
|
||||
err := database.DB.QueryRow(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
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, c.settings,
|
||||
COALESCE(mc.cnt, 0) AS message_count,
|
||||
@@ -305,10 +420,10 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
||||
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
|
||||
) mc ON mc.channel_id = c.id
|
||||
WHERE c.id = $1 AND c.user_id = $2
|
||||
`, channelID, userID).Scan(
|
||||
`), channelID, userID).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
pq.Array(&tags), &ch.Settings,
|
||||
scanTags(&tags), scanJSON(&ch.Settings),
|
||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -348,7 +463,7 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
||||
|
||||
// Verify ownership
|
||||
var ownerID string
|
||||
err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID)
|
||||
err := database.DB.QueryRow(database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID).Scan(&ownerID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
@@ -358,15 +473,13 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Build dynamic UPDATE
|
||||
// Build dynamic UPDATE with ? placeholders (converted for Postgres)
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argN := 1
|
||||
|
||||
addClause := func(col string, val interface{}) {
|
||||
setClauses = append(setClauses, col+" = $"+strconv.Itoa(argN))
|
||||
setClauses = append(setClauses, col+" = ?")
|
||||
args = append(args, val)
|
||||
argN++
|
||||
}
|
||||
|
||||
if req.Title != nil {
|
||||
@@ -394,13 +507,16 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
||||
addClause("folder", *req.Folder)
|
||||
}
|
||||
if req.Tags != nil {
|
||||
addClause("tags", pq.Array(req.Tags))
|
||||
addClause("tags", writeTagsArg(req.Tags))
|
||||
}
|
||||
if req.Settings != nil {
|
||||
// JSONB merge: new settings keys overwrite existing, unmentioned keys preserved
|
||||
setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
|
||||
if database.IsSQLite() {
|
||||
setClauses = append(setClauses, "settings = json_patch(settings, ?)")
|
||||
} else {
|
||||
setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || ?::jsonb")
|
||||
}
|
||||
args = append(args, []byte(*req.Settings))
|
||||
argN++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
@@ -408,16 +524,14 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
query := "UPDATE channels SET "
|
||||
for i, clause := range setClauses {
|
||||
if i > 0 {
|
||||
query += ", "
|
||||
}
|
||||
query += clause
|
||||
}
|
||||
query += " WHERE id = $" + strconv.Itoa(argN) + " AND user_id = $" + strconv.Itoa(argN+1)
|
||||
query := "UPDATE channels SET " + strings.Join(setClauses, ", ")
|
||||
query += " WHERE id = ? AND user_id = ?"
|
||||
args = append(args, channelID, userID)
|
||||
|
||||
if !database.IsSQLite() {
|
||||
query = convertPlaceholders(query)
|
||||
}
|
||||
|
||||
_, err = database.DB.Exec(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
|
||||
@@ -435,7 +549,7 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
|
||||
channelID := c.Param("id")
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
`DELETE FROM channels WHERE id = $1 AND user_id = $2`,
|
||||
database.Q(`DELETE FROM channels WHERE id = $1 AND user_id = $2`),
|
||||
channelID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -647,7 +647,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
if configID == "" {
|
||||
var channelConfigID *string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT provider_config_id FROM channels WHERE id = $1`, channelID,
|
||||
database.Q(`SELECT provider_config_id FROM channels WHERE id = $1`), channelID,
|
||||
).Scan(&channelConfigID)
|
||||
if err == nil && channelConfigID != nil {
|
||||
configID = *channelConfigID
|
||||
@@ -656,7 +656,7 @@ 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(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT id FROM provider_configs
|
||||
WHERE is_active = true AND (
|
||||
(scope = 'personal' AND owner_id = $1)
|
||||
@@ -664,7 +664,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
)
|
||||
ORDER BY scope ASC, created_at ASC
|
||||
LIMIT 1
|
||||
`, userID).Scan(&configID)
|
||||
`), userID).Scan(&configID)
|
||||
if err != nil {
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("no API config found — add one at /api-configs")
|
||||
}
|
||||
@@ -677,7 +677,13 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
var apiKeyEnc, keyNonce []byte
|
||||
var keyScope string
|
||||
var customHeadersJSON, providerSettingsJSON []byte
|
||||
err := database.DB.QueryRow(`
|
||||
// $2/userID appears twice in the query; Postgres reuses positional params,
|
||||
// SQLite needs each ? bound separately.
|
||||
configArgs := []interface{}{configID, userID}
|
||||
if database.IsSQLite() {
|
||||
configArgs = append(configArgs, userID)
|
||||
}
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope,
|
||||
model_default, headers, settings
|
||||
FROM provider_configs
|
||||
@@ -685,7 +691,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
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, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
|
||||
`), configArgs...).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
|
||||
&modelDefault, &customHeadersJSON, &providerSettingsJSON)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -767,7 +773,7 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
|
||||
// ── User/preset system prompt (appended after admin prompt) ──
|
||||
var systemPrompt *string
|
||||
_ = database.DB.QueryRow(
|
||||
`SELECT system_prompt FROM channels WHERE id = $1`, channelID,
|
||||
database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID,
|
||||
).Scan(&systemPrompt)
|
||||
|
||||
// Preset system prompt takes priority; channel system prompt is fallback
|
||||
@@ -882,18 +888,31 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
|
||||
tcVal = string(toolCallsJSON)
|
||||
}
|
||||
|
||||
// Insert with RETURNING id
|
||||
// Insert with RETURNING id (Postgres) or pre-generated id (SQLite)
|
||||
var newID string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model, tokens_used,
|
||||
tool_calls, parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id
|
||||
`, channelID, role, content, model, tokensUsed,
|
||||
tcVal, parentID, participantType, participantID, siblingIdx,
|
||||
).Scan(&newID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
if database.IsSQLite() {
|
||||
newID = store.NewID()
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO messages (id, channel_id, role, content, model, tokens_used,
|
||||
tool_calls, parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?)
|
||||
`, newID, channelID, role, content, model, tokensUsed,
|
||||
tcVal, parentID, participantType, participantID, siblingIdx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model, tokens_used,
|
||||
tool_calls, parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id
|
||||
`, channelID, role, content, model, tokensUsed,
|
||||
tcVal, parentID, participantType, participantID, siblingIdx,
|
||||
).Scan(&newID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Update cursor to point at new message
|
||||
@@ -902,7 +921,7 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
|
||||
}
|
||||
|
||||
// Touch channel updated_at
|
||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||
_, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID)
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ package handlers
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
@@ -88,7 +88,7 @@ func (h *GroupHandler) CreateGroup(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := h.stores.Groups.Create(c.Request.Context(), g); err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
if database.IsUniqueViolation(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "group name already exists in this scope"})
|
||||
return
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
if database.IsUniqueViolation(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "group name already exists in this scope"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,22 +7,101 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||
)
|
||||
|
||||
// ── Test Harness ────────────────────────────
|
||||
|
||||
// dialectSQL converts Postgres-style $N placeholders to ? for SQLite,
|
||||
// strips ::jsonb casts, and converts boolean literals to integers.
|
||||
// Allows raw SQL in tests to work on both backends.
|
||||
func dialectSQL(q string) string {
|
||||
if !database.IsSQLite() {
|
||||
return q
|
||||
}
|
||||
result := q
|
||||
// Replace high-to-low to avoid $1 matching inside $10
|
||||
for i := 20; i >= 1; i-- {
|
||||
result = strings.ReplaceAll(result, fmt.Sprintf("$%d", i), "?")
|
||||
}
|
||||
result = strings.ReplaceAll(result, "::jsonb", "")
|
||||
result = strings.ReplaceAll(result, "::text", "")
|
||||
// Boolean literals: true/false → 1/0 (only bare keywords, not string 'true')
|
||||
result = strings.ReplaceAll(result, "= true", "= 1")
|
||||
result = strings.ReplaceAll(result, "= false", "= 0")
|
||||
result = strings.ReplaceAll(result, ", true)", ", 1)")
|
||||
result = strings.ReplaceAll(result, "COALESCE(is_private, false)", "COALESCE(is_private, 0)")
|
||||
// Time functions
|
||||
result = strings.ReplaceAll(result, "NOW()", "datetime('now')")
|
||||
// NULL sort
|
||||
result = strings.ReplaceAll(result, "NULLS LAST", "")
|
||||
return result
|
||||
}
|
||||
|
||||
// seedID returns a new UUID for use in test seed data.
|
||||
func seedID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// seedInsertReturningID executes an INSERT with RETURNING id on Postgres,
|
||||
// or injects a generated UUID id on SQLite and returns it.
|
||||
func seedInsertReturningID(t *testing.T, query string, args ...interface{}) string {
|
||||
t.Helper()
|
||||
if !database.IsSQLite() {
|
||||
var id string
|
||||
err := database.TestDB.QueryRow(query, args...).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seedInsertReturningID: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
id := seedID()
|
||||
q := dialectSQL(query)
|
||||
// Remove RETURNING clause
|
||||
if idx := strings.Index(strings.ToUpper(q), "RETURNING"); idx >= 0 {
|
||||
q = strings.TrimSpace(q[:idx])
|
||||
}
|
||||
// Inject id column
|
||||
q = database.InjectIDForTest(q)
|
||||
newArgs := append([]interface{}{id}, args...)
|
||||
_, err := database.TestDB.Exec(q, newArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("seedInsertReturningID: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// seedExec executes an INSERT via dialectSQL; on SQLite it also injects
|
||||
// a generated id column and value (first arg) so that TEXT PRIMARY KEY
|
||||
// tables that lack a DEFAULT get a proper UUID.
|
||||
func seedExec(t *testing.T, query string, args ...interface{}) {
|
||||
t.Helper()
|
||||
q := dialectSQL(query)
|
||||
if database.IsSQLite() {
|
||||
q = database.InjectIDForTest(q)
|
||||
args = append([]interface{}{seedID()}, args...)
|
||||
}
|
||||
_, err := database.TestDB.Exec(q, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("seedExec: %v\n query: %s", err, q)
|
||||
}
|
||||
}
|
||||
|
||||
const testJWTSecret = "test-secret-key-for-integration-tests"
|
||||
|
||||
type testHarness struct {
|
||||
@@ -42,7 +121,12 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
BasePath: "",
|
||||
}
|
||||
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
var stores store.Stores
|
||||
if database.IsSQLite() {
|
||||
stores = sqlite.NewStores(database.TestDB)
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
|
||||
// Roles resolver (nil vault — test-fire won't work, but CRUD will)
|
||||
roleResolver := roles.NewResolver(stores, nil)
|
||||
@@ -124,7 +208,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
|
||||
|
||||
// Notes
|
||||
notes := NewNoteHandler()
|
||||
notes := NewNoteHandler(stores)
|
||||
protected.GET("/notes", notes.List)
|
||||
protected.POST("/notes", notes.Create)
|
||||
protected.GET("/notes/:id", notes.Get)
|
||||
@@ -166,6 +250,18 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
completions := NewCompletionHandler(nil, stores, nil, nil)
|
||||
protected.POST("/chat/completions", completions.Complete)
|
||||
|
||||
// Messages
|
||||
msgs := NewMessageHandler(nil, stores, nil, nil)
|
||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
||||
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
|
||||
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
|
||||
protected.GET("/channels/:id/path", msgs.GetActivePath)
|
||||
|
||||
// Avatar (uses settings handler)
|
||||
protected.PUT("/avatar", settings.UploadAvatar)
|
||||
protected.DELETE("/avatar", settings.DeleteAvatar)
|
||||
|
||||
// Admin routes
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
||||
@@ -297,7 +393,7 @@ func (h *testHarness) createAdminUser(username, email string) (userID, token str
|
||||
h.t.Helper()
|
||||
userID = database.SeedTestUser(h.t, username, email)
|
||||
// Make admin
|
||||
database.TestDB.Exec("UPDATE users SET role = 'admin', is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), userID)
|
||||
token = makeToken(userID, email, "admin")
|
||||
return
|
||||
}
|
||||
@@ -486,7 +582,7 @@ func TestIntegration_AdminProviderConfigCRUD(t *testing.T) {
|
||||
// ── Verify API key is actually stored in DB ──
|
||||
var storedKey string
|
||||
err := database.TestDB.QueryRow(
|
||||
"SELECT api_key_enc FROM provider_configs WHERE id = $1", configID,
|
||||
dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID,
|
||||
).Scan(&storedKey)
|
||||
if err != nil {
|
||||
t.Fatalf("query stored key: %v", err)
|
||||
@@ -510,7 +606,7 @@ func TestIntegration_AdminProviderConfigCRUD(t *testing.T) {
|
||||
|
||||
// Verify the key was actually updated
|
||||
err = database.TestDB.QueryRow(
|
||||
"SELECT api_key_enc FROM provider_configs WHERE id = $1", configID,
|
||||
dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID,
|
||||
).Scan(&storedKey)
|
||||
if err != nil {
|
||||
t.Fatalf("query updated key: %v", err)
|
||||
@@ -549,7 +645,7 @@ func TestIntegration_AdminProviderAPIKeyUsedByFetch(t *testing.T) {
|
||||
// Verify key stored in DB
|
||||
var storedKey string
|
||||
database.TestDB.QueryRow(
|
||||
"SELECT api_key_enc FROM provider_configs WHERE id = $1", configID,
|
||||
dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID,
|
||||
).Scan(&storedKey)
|
||||
if storedKey != "sk-badkey-for-test" {
|
||||
t.Fatalf("key not stored: want 'sk-badkey-for-test', got %q — json:\"-\" bug is back", storedKey)
|
||||
@@ -605,13 +701,10 @@ func TestIntegration_ModelVisibilityResolution(t *testing.T) {
|
||||
configID := cfg["id"].(string)
|
||||
|
||||
// Insert a model into catalog directly (simulating fetch)
|
||||
_, err := database.TestDB.Exec(`
|
||||
seedExec(t, `
|
||||
INSERT INTO model_catalog (provider_config_id, model_id, display_name, visibility)
|
||||
VALUES ($1, 'gpt-4o', 'GPT-4o', 'disabled')
|
||||
`, configID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert catalog entry: %v", err)
|
||||
}
|
||||
|
||||
// As admin, models/enabled should return empty (model disabled)
|
||||
w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
|
||||
@@ -627,7 +720,7 @@ func TestIntegration_ModelVisibilityResolution(t *testing.T) {
|
||||
|
||||
// Enable the model
|
||||
var catalogID string
|
||||
database.TestDB.QueryRow("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1", configID).Scan(&catalogID)
|
||||
database.TestDB.QueryRow(dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), configID).Scan(&catalogID)
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/models/%s", catalogID), adminToken,
|
||||
map[string]interface{}{"visibility": "enabled"})
|
||||
if w.Code != http.StatusOK {
|
||||
@@ -654,7 +747,7 @@ func TestIntegration_TeamMemberManagement(t *testing.T) {
|
||||
|
||||
// Create regular user
|
||||
userID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
|
||||
// Create team
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
@@ -920,14 +1013,11 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) {
|
||||
|
||||
// Insert models directly (simulating successful provider fetch)
|
||||
for _, mid := range []string{"gpt-4o", "gpt-4o-mini", "o1-preview"} {
|
||||
_, err := database.TestDB.Exec(`
|
||||
seedExec(t, `
|
||||
INSERT INTO model_catalog (provider_config_id, model_id, display_name,
|
||||
capabilities, visibility)
|
||||
VALUES ($1, $2, $3, '{"streaming":true,"tool_calling":true}'::jsonb, 'disabled')
|
||||
`, configID, mid, mid)
|
||||
if err != nil {
|
||||
t.Fatalf("insert %s: %v", mid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin list should show ALL models (including disabled) ──
|
||||
@@ -949,7 +1039,7 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) {
|
||||
|
||||
// ── User should see 0 models (all disabled) ──
|
||||
userID := database.SeedTestUser(t, "testuser", "user@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "user@test.com", "user")
|
||||
w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
@@ -970,7 +1060,7 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) {
|
||||
// ── Admin enables one model ──
|
||||
var catalogID string
|
||||
database.TestDB.QueryRow(
|
||||
"SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1",
|
||||
dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"),
|
||||
configID,
|
||||
).Scan(&catalogID)
|
||||
|
||||
@@ -1097,13 +1187,10 @@ func hasModelWithScope(models []interface{}, modelID, scope string) bool {
|
||||
func simulateFetch(t *testing.T, providerConfigID string, models []string, visibility string) {
|
||||
t.Helper()
|
||||
for _, modelID := range models {
|
||||
_, err := database.TestDB.Exec(`
|
||||
seedExec(t, `
|
||||
INSERT INTO model_catalog (provider_config_id, model_id, display_name, visibility)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, providerConfigID, modelID, modelID, visibility)
|
||||
if err != nil {
|
||||
t.Fatalf("[SIMULATED FETCH] insert %s: %v", modelID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,7 +1202,7 @@ func TestUserJourney_AdminProvider_UserSeesModels(t *testing.T) {
|
||||
|
||||
// Regular user — no special role, no team
|
||||
userID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "alice@test.com", "user")
|
||||
|
||||
// Step 1: Admin creates provider via API
|
||||
@@ -1144,7 +1231,7 @@ func TestUserJourney_AdminProvider_UserSeesModels(t *testing.T) {
|
||||
// Step 4: Admin enables one model via API
|
||||
var catalogID string
|
||||
database.TestDB.QueryRow(
|
||||
"SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1", configID,
|
||||
dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), configID,
|
||||
).Scan(&catalogID)
|
||||
|
||||
w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
|
||||
@@ -1308,11 +1395,11 @@ func TestUserJourney_TeamProvider_MemberVsNonMember(t *testing.T) {
|
||||
|
||||
// Create team members
|
||||
aliceID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", aliceID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), aliceID)
|
||||
aliceToken := makeToken(aliceID, "alice@test.com", "user")
|
||||
|
||||
bobID := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", bobID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), bobID)
|
||||
bobToken := makeToken(bobID, "bob@test.com", "user")
|
||||
|
||||
// Step 1: Admin creates team via API
|
||||
@@ -1406,15 +1493,15 @@ func TestUserJourney_FullMatrix(t *testing.T) {
|
||||
|
||||
// Create all actors
|
||||
teamAdminID := database.SeedTestUser(t, "teamadmin", "teamadmin@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", teamAdminID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamadmin@test.com", "user")
|
||||
|
||||
teamMemberID := database.SeedTestUser(t, "teammember", "teammember@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", teamMemberID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamMemberID)
|
||||
teamMemberToken := makeToken(teamMemberID, "teammember@test.com", "user")
|
||||
|
||||
outsiderID := database.SeedTestUser(t, "outsider", "outsider@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", outsiderID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), outsiderID)
|
||||
outsiderToken := makeToken(outsiderID, "outsider@test.com", "user")
|
||||
|
||||
// ── Setup: Enable BYOK policy ──
|
||||
@@ -1599,7 +1686,7 @@ func TestUserJourney_FullMatrix(t *testing.T) {
|
||||
t.Run("admin_disables_global_model_users_lose_it", func(t *testing.T) {
|
||||
var catalogID string
|
||||
database.TestDB.QueryRow(
|
||||
"SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1", globalCfgID,
|
||||
dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), globalCfgID,
|
||||
).Scan(&catalogID)
|
||||
|
||||
// Admin disables gpt-4o
|
||||
@@ -1747,7 +1834,7 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
||||
|
||||
// Create team admin user
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", teamAdminID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||
|
||||
// Create team
|
||||
@@ -1823,15 +1910,12 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
||||
// seedUsage inserts a usage_log row directly for testing.
|
||||
func seedUsage(t *testing.T, userID, provCfgID, model, scope string, prompt, completion int, costIn, costOut float64) {
|
||||
t.Helper()
|
||||
_, err := database.TestDB.Exec(`
|
||||
seedExec(t, `
|
||||
INSERT INTO usage_log (user_id, provider_config_id, provider_scope,
|
||||
model_id, prompt_tokens, completion_tokens,
|
||||
cost_input, cost_output)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, userID, provCfgID, scope, model, prompt, completion, costIn, costOut)
|
||||
if err != nil {
|
||||
t.Fatalf("seedUsage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Usage_AdminView(t *testing.T) {
|
||||
@@ -1839,7 +1923,7 @@ func TestIntegration_Usage_AdminView(t *testing.T) {
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
userID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
|
||||
// Create global provider
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
@@ -1881,7 +1965,7 @@ func TestIntegration_Usage_AdminExcludesBYOK(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
userID := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
|
||||
// Create global provider
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
@@ -1992,11 +2076,11 @@ func TestIntegration_Usage_TeamAdmin(t *testing.T) {
|
||||
|
||||
// Create team admin + member
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead2", "teamlead2@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", teamAdminID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead2@test.com", "user")
|
||||
|
||||
memberID := database.SeedTestUser(t, "member2", "member2@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", memberID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), memberID)
|
||||
|
||||
// Create team
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
@@ -2060,7 +2144,7 @@ func TestIntegration_Usage_TeamNonAdmin403(t *testing.T) {
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
memberID := database.SeedTestUser(t, "member3", "member3@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", memberID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), memberID)
|
||||
memberToken := makeToken(memberID, "member3@test.com", "user")
|
||||
|
||||
// Create team, add member (NOT admin)
|
||||
@@ -2179,7 +2263,7 @@ func TestIntegration_Pricing_ExcludesBYOK(t *testing.T) {
|
||||
byokID := bcfg["id"].(string)
|
||||
|
||||
// Simulate catalog pricing for BYOK provider (as model sync would)
|
||||
database.TestDB.Exec(`
|
||||
seedExec(t, `
|
||||
INSERT INTO model_pricing (provider_config_id, model_id, input_per_m, output_per_m, source)
|
||||
VALUES ($1, 'gpt-4o-byok', 3.0, 15.0, 'catalog')
|
||||
`, byokID)
|
||||
@@ -2555,7 +2639,7 @@ func TestGroupMembers(t *testing.T) {
|
||||
|
||||
_, adminToken := h.createAdminUser("gmadmin", "gmadmin@test.com")
|
||||
userID := database.SeedTestUser(h.t, "gmuser", "gmuser@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "gmuser@test.com", "user")
|
||||
|
||||
// Create group
|
||||
@@ -2734,7 +2818,7 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
||||
// Setup: admin + regular user (not on any team)
|
||||
adminID, adminToken := h.createAdminUser("gpadmin", "gpadmin@test.com")
|
||||
userID := database.SeedTestUser(h.t, "gpuser", "gpuser@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "gpuser@test.com", "user")
|
||||
|
||||
// Create a team
|
||||
@@ -2746,15 +2830,11 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
||||
teamID := teamResp["id"].(string)
|
||||
|
||||
// Create persona scoped to that team (user shouldn't see it without group access)
|
||||
var personaID string
|
||||
err := database.DB.QueryRow(`
|
||||
personaID := seedInsertReturningID(t, `
|
||||
INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
|
||||
VALUES ('Secret Bot', 'test-model', 'team', $1, $2, true)
|
||||
RETURNING id
|
||||
`, teamID, adminID).Scan(&personaID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert persona: %v", err)
|
||||
}
|
||||
`, teamID, adminID)
|
||||
if personaID == "" {
|
||||
t.Fatal("personaID is empty after insert")
|
||||
}
|
||||
@@ -3035,3 +3115,186 @@ func TestIntegration_KB_DirectAccessPolicy(t *testing.T) {
|
||||
// (if kb_direct_access INSERT fails, the migration fails and no tests run)
|
||||
_ = w
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Messages + Treepath tests (SQLite compat)
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
func TestIntegration_Messages_CRUD(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, token := h.createAdminUser("msguser", "msg@test.com")
|
||||
|
||||
// Create channel
|
||||
w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
|
||||
"title": "Message Test",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Create first message
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello, world!",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create message: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var msg1 map[string]interface{}
|
||||
decode(w, &msg1)
|
||||
if msg1["id"] == nil || msg1["id"].(string) == "" {
|
||||
t.Fatal("message should have an id")
|
||||
}
|
||||
if msg1["content"].(string) != "Hello, world!" {
|
||||
t.Fatalf("content mismatch: got %q", msg1["content"])
|
||||
}
|
||||
msg1ID := msg1["id"].(string)
|
||||
|
||||
// Create second message (child of first)
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"content": "Hi there!",
|
||||
"parent_id": msg1ID,
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create message 2: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List messages — should have 2
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list messages: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var listResp map[string]interface{}
|
||||
decode(w, &listResp)
|
||||
total := int(listResp["total"].(float64))
|
||||
if total != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Messages_EditFork(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, token := h.createAdminUser("forkuser", "fork@test.com")
|
||||
|
||||
// Create channel
|
||||
w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
|
||||
"title": "Fork Test",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create channel: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Create user message (root)
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "First draft",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create message: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var msg map[string]interface{}
|
||||
decode(w, &msg)
|
||||
msgID := msg["id"].(string)
|
||||
|
||||
// Edit (fork) the message — creates a sibling
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages/%s/edit", channelID, msgID), token, map[string]interface{}{
|
||||
"content": "Second draft",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("edit message: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var edited map[string]interface{}
|
||||
decode(w, &edited)
|
||||
if edited["content"].(string) != "Second draft" {
|
||||
t.Fatalf("edited content mismatch: got %q", edited["content"])
|
||||
}
|
||||
|
||||
// Sibling count should be 2 (original + edit)
|
||||
sibCount := int(edited["sibling_count"].(float64))
|
||||
if sibCount != 2 {
|
||||
t.Fatalf("expected sibling_count=2, got %d", sibCount)
|
||||
}
|
||||
|
||||
// Verify via siblings endpoint
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/messages/%s/siblings", channelID, msgID), token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list siblings: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var sibResp map[string]interface{}
|
||||
decode(w, &sibResp)
|
||||
siblings := sibResp["siblings"].([]interface{})
|
||||
if len(siblings) != 2 {
|
||||
t.Fatalf("expected 2 siblings, got %d", len(siblings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Messages_TreePath(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, token := h.createAdminUser("pathuser", "path@test.com")
|
||||
|
||||
// Create channel
|
||||
w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
|
||||
"title": "Path Test",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create channel: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Build a 3-message chain: root → child → grandchild
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
|
||||
"role": "user", "content": "root message",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("msg1: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var m1 map[string]interface{}
|
||||
decode(w, &m1)
|
||||
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
|
||||
"role": "assistant", "content": "response", "parent_id": m1["id"].(string),
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("msg2: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var m2 map[string]interface{}
|
||||
decode(w, &m2)
|
||||
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
|
||||
"role": "user", "content": "follow-up", "parent_id": m2["id"].(string),
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("msg3: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Get active path — should return 3 messages in root-first order
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/path", channelID), token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get path: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var pathEnv map[string]interface{}
|
||||
decode(w, &pathEnv)
|
||||
pathResp := pathEnv["path"].([]interface{})
|
||||
if len(pathResp) != 3 {
|
||||
t.Fatalf("expected 3 messages in path, got %d", len(pathResp))
|
||||
}
|
||||
|
||||
// Verify order: root first, grandchild last
|
||||
first := pathResp[0].(map[string]interface{})
|
||||
last := pathResp[2].(map[string]interface{})
|
||||
if first["content"].(string) != "root message" {
|
||||
t.Fatalf("first in path should be root, got %q", first["content"])
|
||||
}
|
||||
if last["content"].(string) != "follow-up" {
|
||||
t.Fatalf("last in path should be follow-up, got %q", last["content"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ func (h *KnowledgeBaseHandler) CreateKB(c *gin.Context) {
|
||||
if role != "admin" {
|
||||
var teamRole string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`,
|
||||
database.Q(`SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`),
|
||||
req.TeamID, userID).Scan(&teamRole)
|
||||
if err != nil || teamRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required to create team knowledge bases"})
|
||||
|
||||
@@ -11,38 +11,48 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/treepath"
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Live Compaction Tests
|
||||
// ═══════════════════════════════════════════
|
||||
// Requires: TEST_DATABASE_URL + VENICE_API_KEY
|
||||
// Requires: TEST_DATABASE_URL + PROVIDER_KEY (or VENICE_API_KEY)
|
||||
//
|
||||
// Tests the full Compact() pipeline end-to-end:
|
||||
// seed messages → call utility model → verify summary tree node
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// dialectStores returns the correct store bundle for the active dialect.
|
||||
func dialectStores() store.Stores {
|
||||
if database.IsSQLite() {
|
||||
return sqlite.NewStores(database.TestDB)
|
||||
}
|
||||
return postgres.NewStores(database.TestDB)
|
||||
}
|
||||
|
||||
func TestLive_CompactionFullPipeline(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
pc := requireLiveProvider(t)
|
||||
userID, adminToken := h.createAdminUser("compactadmin", "compact@test.com")
|
||||
|
||||
// Set up Venice provider + enable qwen3-4b
|
||||
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
|
||||
// Set up provider + enable first model
|
||||
configID, modelID := setupProviderWithModel(t, h, adminToken, pc)
|
||||
|
||||
// Configure utility role → qwen3-4b
|
||||
// Configure utility role
|
||||
w := h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
|
||||
"primary": map[string]interface{}{
|
||||
"provider_config_id": configID,
|
||||
"model_id": veniceTestModel,
|
||||
"model_id": modelID,
|
||||
},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("configure utility role: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
t.Log(" ✓ Utility role configured with", veniceTestModel)
|
||||
t.Log(" ✓ Utility role configured with", modelID)
|
||||
|
||||
// Create a channel with enough messages to summarize
|
||||
w = h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
|
||||
@@ -69,33 +79,30 @@ func TestLive_CompactionFullPipeline(t *testing.T) {
|
||||
{"assistant", "Early morning is best for temples. Fushimi Inari at sunrise (5-6am) is magical and nearly empty. Kinkaku-ji opens at 9am — arrive right at opening. Arashiyama bamboo grove is best before 8am. For Senso-ji in Tokyo, go at dawn for beautiful photos. Weekdays are always less crowded than weekends."},
|
||||
}
|
||||
|
||||
ph := database.PH
|
||||
var lastMsgID string
|
||||
for _, m := range msgs {
|
||||
var parentPtr *string
|
||||
if lastMsgID != "" {
|
||||
parentPtr = &lastMsgID
|
||||
}
|
||||
err := database.TestDB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, parent_id, role, content, sibling_index)
|
||||
VALUES ($1, $2, $3, $4, 0)
|
||||
RETURNING id
|
||||
`, channelID, parentPtr, m.role, m.content).Scan(&lastMsgID)
|
||||
err := database.TestDB.QueryRow(
|
||||
"INSERT INTO messages (channel_id, parent_id, role, content, sibling_index) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+", "+ph(4)+", 0) RETURNING id",
|
||||
channelID, parentPtr, m.role, m.content).Scan(&lastMsgID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set cursor to last message
|
||||
database.TestDB.Exec(`
|
||||
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
|
||||
`, channelID, userID, lastMsgID)
|
||||
database.TestDB.Exec(
|
||||
"INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+") ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = excluded.active_leaf_id",
|
||||
channelID, userID, lastMsgID)
|
||||
|
||||
t.Logf(" ✓ Seeded %d messages in channel %s", len(msgs), channelID)
|
||||
|
||||
// ── Run compaction ──
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
stores := dialectStores()
|
||||
resolver := roles.NewResolver(stores, nil)
|
||||
svc := compaction.NewService(stores, resolver)
|
||||
|
||||
@@ -119,16 +126,16 @@ func TestLive_CompactionFullPipeline(t *testing.T) {
|
||||
if result.Content == "" {
|
||||
t.Fatal("summary content should not be empty")
|
||||
}
|
||||
if result.Model != veniceTestModel {
|
||||
t.Errorf("model = %q, want %q", result.Model, veniceTestModel)
|
||||
if result.Model != modelID {
|
||||
t.Errorf("model = %q, want %q", result.Model, modelID)
|
||||
}
|
||||
|
||||
// Verify summary message exists in the tree
|
||||
var summaryContent, summaryRole string
|
||||
var metadataRaw []byte
|
||||
err = database.TestDB.QueryRow(`
|
||||
SELECT role, content, metadata FROM messages WHERE id = $1
|
||||
`, result.SummaryID).Scan(&summaryRole, &summaryContent, &metadataRaw)
|
||||
err = database.TestDB.QueryRow(
|
||||
"SELECT role, content, metadata FROM messages WHERE id = "+ph(1),
|
||||
result.SummaryID).Scan(&summaryRole, &summaryContent, &metadataRaw)
|
||||
if err != nil {
|
||||
t.Fatalf("query summary message: %v", err)
|
||||
}
|
||||
@@ -152,7 +159,6 @@ func TestLive_CompactionFullPipeline(t *testing.T) {
|
||||
t.Fatalf("GetActivePath after compaction: %v", err)
|
||||
}
|
||||
|
||||
// The last message in the path should be the summary
|
||||
if len(path) == 0 {
|
||||
t.Fatal("path should not be empty after compaction")
|
||||
}
|
||||
@@ -164,9 +170,9 @@ func TestLive_CompactionFullPipeline(t *testing.T) {
|
||||
|
||||
// Verify usage was logged
|
||||
var usageCount int
|
||||
database.TestDB.QueryRow(`
|
||||
SELECT COUNT(*) FROM usage_log WHERE channel_id = $1 AND role = 'utility'
|
||||
`, channelID).Scan(&usageCount)
|
||||
database.TestDB.QueryRow(
|
||||
"SELECT COUNT(*) FROM usage_log WHERE channel_id = "+ph(1)+" AND role = 'utility'",
|
||||
channelID).Scan(&usageCount)
|
||||
if usageCount == 0 {
|
||||
t.Error("expected usage_log entry for utility role")
|
||||
}
|
||||
@@ -177,24 +183,31 @@ func TestLive_CompactionFullPipeline(t *testing.T) {
|
||||
// rejects input that exceeds the utility model's context window.
|
||||
func TestLive_CompactionContextBudgetGuardRail(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
pc := requireLiveProvider(t)
|
||||
_, adminToken := h.createAdminUser("guardrail_admin", "guardrail@test.com")
|
||||
userID := database.SeedTestUser(t, "guardrail_user", "gruser@test.com")
|
||||
|
||||
// Set up Venice + qwen3-4b (32K context)
|
||||
configID, catalogEntryID := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
|
||||
// Set up provider + enable first model
|
||||
configID, modelID := setupProviderWithModel(t, h, adminToken, pc)
|
||||
|
||||
ph := database.PH
|
||||
|
||||
// Set max_context to 32000 in catalog (in case sync didn't populate it)
|
||||
database.TestDB.Exec(`
|
||||
UPDATE model_catalog SET capabilities = capabilities || '{"max_context": 32000}'::jsonb
|
||||
WHERE id = $1
|
||||
`, catalogEntryID)
|
||||
if database.IsSQLite() {
|
||||
database.TestDB.Exec(
|
||||
"UPDATE model_catalog SET capabilities = json_set(COALESCE(capabilities,'{}'), '$.max_context', 32000) WHERE provider_config_id = "+ph(1)+" AND model_id = "+ph(2),
|
||||
configID, modelID)
|
||||
} else {
|
||||
database.TestDB.Exec(
|
||||
"UPDATE model_catalog SET capabilities = capabilities || '{\"max_context\": 32000}'::jsonb WHERE provider_config_id = "+ph(1)+" AND model_id = "+ph(2),
|
||||
configID, modelID)
|
||||
}
|
||||
|
||||
// Configure utility role
|
||||
h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
|
||||
"primary": map[string]interface{}{
|
||||
"provider_config_id": configID,
|
||||
"model_id": veniceTestModel,
|
||||
"model_id": modelID,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -207,7 +220,6 @@ func TestLive_CompactionContextBudgetGuardRail(t *testing.T) {
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Seed 20 messages × 8000 chars each = 160K chars ≈ 40K tokens
|
||||
// This exceeds 32K × 0.80 = 25.6K token ceiling
|
||||
bigContent := make([]byte, 8000)
|
||||
for i := range bigContent {
|
||||
bigContent[i] = 'a'
|
||||
@@ -222,19 +234,16 @@ func TestLive_CompactionContextBudgetGuardRail(t *testing.T) {
|
||||
if i%2 == 1 {
|
||||
role = "assistant"
|
||||
}
|
||||
database.TestDB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, parent_id, role, content, sibling_index)
|
||||
VALUES ($1, $2, $3, $4, 0) RETURNING id
|
||||
`, channelID, parentPtr, role, string(bigContent)).Scan(&lastMsgID)
|
||||
database.TestDB.QueryRow(
|
||||
"INSERT INTO messages (channel_id, parent_id, role, content, sibling_index) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+", "+ph(4)+", 0) RETURNING id",
|
||||
channelID, parentPtr, role, string(bigContent)).Scan(&lastMsgID)
|
||||
}
|
||||
database.TestDB.Exec(`
|
||||
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
|
||||
`, channelID, userID, lastMsgID)
|
||||
database.TestDB.Exec(
|
||||
"INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+") ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = excluded.active_leaf_id",
|
||||
channelID, userID, lastMsgID)
|
||||
|
||||
// Run compaction — should fail with context budget error
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
stores := dialectStores()
|
||||
resolver := roles.NewResolver(stores, nil)
|
||||
svc := compaction.NewService(stores, resolver)
|
||||
|
||||
@@ -250,7 +259,6 @@ func TestLive_CompactionContextBudgetGuardRail(t *testing.T) {
|
||||
|
||||
t.Logf(" ✓ Guard rail triggered: %v", err)
|
||||
|
||||
// Verify it's specifically a context budget error
|
||||
if !strings.Contains(err.Error(), "context window") {
|
||||
t.Errorf("expected context window error, got: %v", err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
@@ -16,37 +17,74 @@ import (
|
||||
// ═══════════════════════════════════════════
|
||||
// These tests require:
|
||||
// - TEST_DATABASE_URL or PGHOST+PGUSER
|
||||
// - VENICE_API_KEY secret
|
||||
// - PROVIDER + PROVIDER_KEY (or legacy VENICE_API_KEY)
|
||||
//
|
||||
// Provider config env vars:
|
||||
// PROVIDER — provider type: "venice", "openai", "anthropic" (default: "venice")
|
||||
// PROVIDER_KEY — API key (falls back to VENICE_API_KEY for compat)
|
||||
// PROVIDER_URL — endpoint override (optional, uses default for known providers)
|
||||
//
|
||||
// They exercise the full flow: create provider →
|
||||
// fetch models → enable model → resolve → complete.
|
||||
//
|
||||
// Model: qwen3-4b (Venice Small) — cheapest at $0.05/$0.15 per 1M tokens
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
const veniceTestModel = "qwen3-4b"
|
||||
|
||||
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
|
||||
// liveProviderConfig holds resolved provider settings for live tests.
|
||||
type liveProviderConfig struct {
|
||||
Provider string // "venice", "openai", "anthropic"
|
||||
Key string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
// setupVeniceWithModel creates a Venice provider, fetches models, and enables
|
||||
// the specified model. Returns (configID, catalogEntryID).
|
||||
func setupVeniceWithModel(t *testing.T, h *testHarness, adminToken, apiKey, modelID string) (string, string) {
|
||||
// defaultEndpoints maps provider names to their default API endpoints.
|
||||
var defaultEndpoints = map[string]string{
|
||||
"venice": "https://api.venice.ai/api/v1",
|
||||
"openai": "https://api.openai.com/v1",
|
||||
"anthropic": "https://api.anthropic.com/v1",
|
||||
}
|
||||
|
||||
// requireLiveProvider resolves provider config from env vars and skips if not configured.
|
||||
func requireLiveProvider(t *testing.T) liveProviderConfig {
|
||||
t.Helper()
|
||||
|
||||
key := os.Getenv("PROVIDER_KEY")
|
||||
if key == "" {
|
||||
// Legacy fallback
|
||||
key = os.Getenv("VENICE_API_KEY")
|
||||
}
|
||||
if key == "" {
|
||||
t.Skip("PROVIDER_KEY (or VENICE_API_KEY) not set — skipping live provider test")
|
||||
}
|
||||
|
||||
provider := os.Getenv("PROVIDER")
|
||||
if provider == "" {
|
||||
provider = "venice" // default
|
||||
}
|
||||
|
||||
endpoint := os.Getenv("PROVIDER_URL")
|
||||
if endpoint == "" {
|
||||
var ok bool
|
||||
endpoint, ok = defaultEndpoints[provider]
|
||||
if !ok {
|
||||
t.Fatalf("PROVIDER=%q has no default endpoint — set PROVIDER_URL", provider)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf(" Live provider: %s @ %s", provider, endpoint)
|
||||
return liveProviderConfig{Provider: provider, Key: key, Endpoint: endpoint}
|
||||
}
|
||||
|
||||
// setupProviderWithModel creates a provider config, fetches models, and enables
|
||||
// the first available model. Returns (configID, enabledModelID).
|
||||
func setupProviderWithModel(t *testing.T, h *testHarness, adminToken string, pc liveProviderConfig) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
// Create provider
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "Venice Test", "provider": "venice",
|
||||
"endpoint": "https://api.venice.ai/api/v1", "api_key": apiKey,
|
||||
"name": pc.Provider + " Test", "provider": pc.Provider,
|
||||
"endpoint": pc.Endpoint, "api_key": pc.Key,
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create venice config: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("create %s config: want 201, got %d: %s", pc.Provider, w.Code, w.Body.String())
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
@@ -59,21 +97,52 @@ func setupVeniceWithModel(t *testing.T, h *testHarness, adminToken, apiKey, mode
|
||||
t.Fatalf("fetch models: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Find and enable target model
|
||||
// Find and enable a model.
|
||||
// Prefer non-reasoning models: they're cheaper and don't require
|
||||
// minimum thinking budget tokens (which causes 400s with low max_tokens).
|
||||
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
|
||||
var modelsResp map[string]interface{}
|
||||
decode(w, &modelsResp)
|
||||
|
||||
var catalogID string
|
||||
var catalogID, modelID string
|
||||
var fallbackCatalogID, fallbackModelID string
|
||||
|
||||
for _, raw := range modelsResp["models"].([]interface{}) {
|
||||
m := raw.(map[string]interface{})
|
||||
if m["model_id"].(string) == modelID {
|
||||
catalogID = m["id"].(string)
|
||||
if m["visibility"].(string) != "disabled" {
|
||||
continue
|
||||
}
|
||||
|
||||
mid := m["model_id"].(string)
|
||||
cid := m["id"].(string)
|
||||
|
||||
// Save first disabled model as fallback
|
||||
if fallbackCatalogID == "" {
|
||||
fallbackCatalogID = cid
|
||||
fallbackModelID = mid
|
||||
}
|
||||
|
||||
// Check if this is a reasoning model — skip it if possible
|
||||
isReasoning := false
|
||||
if caps, ok := m["capabilities"].(map[string]interface{}); ok {
|
||||
if r, exists := caps["reasoning"]; exists && r == true {
|
||||
isReasoning = true
|
||||
}
|
||||
}
|
||||
if !isReasoning {
|
||||
catalogID = cid
|
||||
modelID = mid
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to any disabled model if all are reasoning models
|
||||
if catalogID == "" {
|
||||
t.Fatalf("model %s not found in Venice catalog", modelID)
|
||||
catalogID = fallbackCatalogID
|
||||
modelID = fallbackModelID
|
||||
}
|
||||
if catalogID == "" {
|
||||
t.Fatal("no disabled model found to enable after fetch")
|
||||
}
|
||||
|
||||
w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
|
||||
@@ -82,35 +151,35 @@ func setupVeniceWithModel(t *testing.T, h *testHarness, adminToken, apiKey, mode
|
||||
t.Fatalf("enable model: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
t.Logf(" Venice provider %s ready, model %s enabled", configID, modelID)
|
||||
return configID, catalogID
|
||||
t.Logf(" Provider %s ready, model %s enabled", configID, modelID)
|
||||
return configID, modelID
|
||||
}
|
||||
|
||||
// TestLive_VeniceProviderFullFlow exercises the complete admin workflow:
|
||||
// TestLive_ProviderFullFlow exercises the complete admin workflow:
|
||||
// create provider → fetch models → enable a model → user sees it → chat completion
|
||||
func TestLive_VeniceProviderFullFlow(t *testing.T) {
|
||||
func TestLive_ProviderFullFlow(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
pc := requireLiveProvider(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// ── 1. Create Venice provider config ────
|
||||
t.Log("Step 1: Creating Venice provider config")
|
||||
// ── 1. Create provider config ────────────
|
||||
t.Log("Step 1: Creating 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,
|
||||
"name": pc.Provider + " Live Test",
|
||||
"provider": pc.Provider,
|
||||
"endpoint": pc.Endpoint,
|
||||
"api_key": pc.Key,
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create venice config: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("create 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")
|
||||
// ── 2. Fetch models ─────────────────────
|
||||
t.Log("Step 2: Fetching models from provider API")
|
||||
w = h.request("POST", "/api/v1/admin/models/fetch", adminToken, map[string]interface{}{
|
||||
"provider_config_id": configID,
|
||||
})
|
||||
@@ -121,136 +190,111 @@ func TestLive_VeniceProviderFullFlow(t *testing.T) {
|
||||
decode(w, &fetchResp)
|
||||
totalFetched := fetchResp["total"].(float64)
|
||||
if totalFetched < 1 {
|
||||
t.Fatalf("Venice should return at least 1 model, got %.0f", totalFetched)
|
||||
t.Fatalf("provider should return at least 1 model, got %.0f", totalFetched)
|
||||
}
|
||||
t.Logf(" Fetched %.0f models from Venice", totalFetched)
|
||||
t.Logf(" Fetched %.0f models", totalFetched)
|
||||
|
||||
// ── 3. List catalog models (all disabled by default) ──
|
||||
t.Log("Step 3: Listing catalog models")
|
||||
// ── 3. List + enable first model ────────
|
||||
t.Log("Step 3: Enabling first available model")
|
||||
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
|
||||
var enableID, enableModelID string
|
||||
var fallbackID, fallbackModelID 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
|
||||
if m["visibility"].(string) != "disabled" {
|
||||
continue
|
||||
}
|
||||
mid := m["model_id"].(string)
|
||||
cid := m["id"].(string)
|
||||
if fallbackID == "" {
|
||||
fallbackID = cid
|
||||
fallbackModelID = mid
|
||||
}
|
||||
// Prefer non-reasoning models (cheaper, no thinking budget requirement)
|
||||
isReasoning := false
|
||||
if caps, ok := m["capabilities"].(map[string]interface{}); ok {
|
||||
if r, exists := caps["reasoning"]; exists && r == true {
|
||||
isReasoning = true
|
||||
}
|
||||
}
|
||||
if !isReasoning {
|
||||
enableID = cid
|
||||
enableModelID = mid
|
||||
break
|
||||
}
|
||||
}
|
||||
if enableID == "" {
|
||||
enableID = fallbackID
|
||||
enableModelID = fallbackModelID
|
||||
}
|
||||
if enableID == "" {
|
||||
t.Fatal("no disabled model found to enable")
|
||||
}
|
||||
t.Logf(" Will enable: %s (catalog ID: %s)", enableModelID, enableID)
|
||||
t.Logf(" Enabling: %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)")
|
||||
// ── 4. Admin sees enabled model ─────────
|
||||
t.Log("Step 4: 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")
|
||||
t.Fatal("models/enabled should return at least 1 model")
|
||||
}
|
||||
|
||||
// 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")
|
||||
t.Error("enabled model must have config_id")
|
||||
}
|
||||
if m["provider_name"] == nil || m["provider_name"] == "" {
|
||||
t.Error("enabled model must have provider_name for display")
|
||||
t.Error("enabled model must have provider_name")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("enabled model %s not found in models/enabled response", enableModelID)
|
||||
t.Errorf("model %s not found in models/enabled", enableModelID)
|
||||
}
|
||||
|
||||
// ── 6. Verify a REGULAR USER also sees the model ──
|
||||
t.Log("Step 6: Verifying models/enabled (regular user)")
|
||||
// ── 5. Regular user also sees model ─────
|
||||
t.Log("Step 5: 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)
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = "+database.PH(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)
|
||||
t.Fatal("regular user should see at least 1 enabled model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLive_VeniceFetchModelsCapabilities verifies that Venice model
|
||||
// capabilities are correctly parsed into the catalog.
|
||||
func TestLive_VeniceFetchModelsCapabilities(t *testing.T) {
|
||||
// TestLive_FetchModelsCapabilities verifies that model capabilities
|
||||
// are correctly parsed into the catalog.
|
||||
func TestLive_FetchModelsCapabilities(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
pc := requireLiveProvider(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,
|
||||
"name": pc.Provider + " Caps Test", "provider": pc.Provider,
|
||||
"endpoint": pc.Endpoint, "api_key": pc.Key,
|
||||
})
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
@@ -259,7 +303,6 @@ func TestLive_VeniceFetchModelsCapabilities(t *testing.T) {
|
||||
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)
|
||||
@@ -272,12 +315,12 @@ func TestLive_VeniceFetchModelsCapabilities(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
// streaming should always be true for Venice
|
||||
// streaming should be true for most providers
|
||||
if caps["streaming"] != true {
|
||||
t.Errorf("model %s: streaming should be true", m["model_id"])
|
||||
t.Logf(" note: model %s streaming=%v", m["model_id"], caps["streaming"])
|
||||
}
|
||||
|
||||
// Verify capabilities are actual booleans (not strings)
|
||||
// Verify capabilities are actual booleans
|
||||
for _, key := range []string{"streaming", "vision", "tool_calling", "reasoning"} {
|
||||
if v, exists := caps[key]; exists {
|
||||
if _, ok := v.(bool); !ok {
|
||||
@@ -288,16 +331,14 @@ func TestLive_VeniceFetchModelsCapabilities(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLive_VeniceChatCompletion sends an actual non-streaming chat completion
|
||||
// using the cheapest model (qwen3-4b = $0.05/$0.15 per 1M tokens).
|
||||
func TestLive_VeniceChatCompletion(t *testing.T) {
|
||||
// TestLive_ChatCompletion sends an actual non-streaming chat completion.
|
||||
func TestLive_ChatCompletion(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
pc := requireLiveProvider(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
|
||||
configID, modelID := setupProviderWithModel(t, h, adminToken, pc)
|
||||
|
||||
// Create channel
|
||||
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
|
||||
"title": "Chat Test", "type": "direct",
|
||||
})
|
||||
@@ -308,15 +349,14 @@ func TestLive_VeniceChatCompletion(t *testing.T) {
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Non-streaming completion with correct field names
|
||||
stream := false
|
||||
w = h.request("POST", "/api/v1/chat/completions", adminToken, map[string]interface{}{
|
||||
"channel_id": channelID,
|
||||
"content": "Say ok",
|
||||
"model": veniceTestModel,
|
||||
"model": modelID,
|
||||
"provider_config_id": configID,
|
||||
"stream": &stream,
|
||||
"max_tokens": 10,
|
||||
"max_tokens": 1200,
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("completion: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -324,16 +364,15 @@ func TestLive_VeniceChatCompletion(t *testing.T) {
|
||||
t.Logf(" ✓ Completion succeeded: %s", w.Body.String()[:min(200, w.Body.Len())])
|
||||
}
|
||||
|
||||
// TestLive_VeniceUsageLogging verifies that a non-streaming completion
|
||||
// creates a usage_log row with token counts from the provider.
|
||||
func TestLive_VeniceUsageLogging(t *testing.T) {
|
||||
// TestLive_UsageLogging verifies that a non-streaming completion
|
||||
// creates a usage_log row with token counts.
|
||||
func TestLive_UsageLogging(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
pc := requireLiveProvider(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
|
||||
configID, modelID := setupProviderWithModel(t, h, adminToken, pc)
|
||||
|
||||
// Create channel
|
||||
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
|
||||
"title": "Usage Test", "type": "direct",
|
||||
})
|
||||
@@ -342,55 +381,44 @@ func TestLive_VeniceUsageLogging(t *testing.T) {
|
||||
}
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Non-streaming — providers reliably return usage in non-streaming mode
|
||||
stream := false
|
||||
w = h.request("POST", "/api/v1/chat/completions", adminToken, map[string]interface{}{
|
||||
"channel_id": channelID,
|
||||
"channel_id": ch["id"].(string),
|
||||
"content": "Say ok",
|
||||
"model": veniceTestModel,
|
||||
"model": modelID,
|
||||
"provider_config_id": configID,
|
||||
"stream": &stream,
|
||||
"max_tokens": 10,
|
||||
"max_tokens": 1200,
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("completion: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify usage_log row exists
|
||||
var rowCount int
|
||||
var promptTokens, completionTokens int
|
||||
err := database.TestDB.QueryRow(`
|
||||
SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0)
|
||||
FROM usage_log WHERE provider_config_id = $1
|
||||
`, configID).Scan(&rowCount, &promptTokens, &completionTokens)
|
||||
var rowCount, promptTokens, completionTokens int
|
||||
err := database.TestDB.QueryRow(
|
||||
"SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0) FROM usage_log WHERE provider_config_id = "+database.PH(1),
|
||||
configID).Scan(&rowCount, &promptTokens, &completionTokens)
|
||||
if err != nil {
|
||||
t.Fatalf("query usage_log: %v", err)
|
||||
}
|
||||
if rowCount == 0 {
|
||||
t.Fatal("usage_log should have a row after non-streaming completion")
|
||||
t.Fatal("usage_log should have a row after completion")
|
||||
}
|
||||
t.Logf(" ✓ Usage logged: %d row(s), prompt=%d completion=%d", rowCount, promptTokens, completionTokens)
|
||||
|
||||
if promptTokens == 0 {
|
||||
t.Fatal("non-streaming completion should report prompt tokens — check provider response parsing")
|
||||
t.Fatal("completion should report prompt tokens")
|
||||
}
|
||||
t.Logf(" ✓ Token counts: prompt=%d completion=%d", promptTokens, completionTokens)
|
||||
t.Logf(" ✓ Usage: %d row(s), prompt=%d completion=%d", rowCount, promptTokens, completionTokens)
|
||||
}
|
||||
|
||||
// TestLive_VeniceStreamingUsageLogging verifies that streaming completions
|
||||
// create a usage_log row with actual token counts. Venice supports
|
||||
// stream_options.include_usage — the parser must capture the usage chunk
|
||||
// that arrives after finish_reason but before [DONE].
|
||||
func TestLive_VeniceStreamingUsageLogging(t *testing.T) {
|
||||
// TestLive_StreamingUsageLogging verifies streaming completions log usage.
|
||||
func TestLive_StreamingUsageLogging(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
pc := requireLiveProvider(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
|
||||
configID, modelID := setupProviderWithModel(t, h, adminToken, pc)
|
||||
|
||||
// Create channel
|
||||
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
|
||||
"title": "Stream Usage Test", "type": "direct",
|
||||
})
|
||||
@@ -399,30 +427,24 @@ func TestLive_VeniceStreamingUsageLogging(t *testing.T) {
|
||||
}
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Streaming completion
|
||||
stream := true
|
||||
w = h.request("POST", "/api/v1/chat/completions", adminToken, map[string]interface{}{
|
||||
"channel_id": channelID,
|
||||
"channel_id": ch["id"].(string),
|
||||
"content": "Say ok",
|
||||
"model": veniceTestModel,
|
||||
"model": modelID,
|
||||
"provider_config_id": configID,
|
||||
"stream": &stream,
|
||||
"max_tokens": 10,
|
||||
"max_tokens": 1200,
|
||||
})
|
||||
// Streaming returns 200 with SSE — the recorder captures the full body
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("streaming completion: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify usage_log row exists (even if tokens are 0)
|
||||
var rowCount int
|
||||
var promptTokens, completionTokens int
|
||||
err := database.TestDB.QueryRow(`
|
||||
SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0)
|
||||
FROM usage_log WHERE provider_config_id = $1
|
||||
`, configID).Scan(&rowCount, &promptTokens, &completionTokens)
|
||||
var rowCount, promptTokens, completionTokens int
|
||||
err := database.TestDB.QueryRow(
|
||||
"SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0) FROM usage_log WHERE provider_config_id = "+database.PH(1),
|
||||
configID).Scan(&rowCount, &promptTokens, &completionTokens)
|
||||
if err != nil {
|
||||
t.Fatalf("query usage_log: %v", err)
|
||||
}
|
||||
@@ -430,46 +452,29 @@ func TestLive_VeniceStreamingUsageLogging(t *testing.T) {
|
||||
t.Fatal("usage_log should have a row after streaming completion")
|
||||
}
|
||||
if promptTokens == 0 {
|
||||
t.Fatal("streaming completion should report prompt tokens — check pendingFinish logic in openai.go parser")
|
||||
t.Fatal("streaming completion should report prompt tokens")
|
||||
}
|
||||
t.Logf(" ✓ Streaming usage logged: %d row(s), prompt=%d completion=%d", rowCount, promptTokens, completionTokens)
|
||||
t.Logf(" ✓ Streaming usage: %d row(s), prompt=%d completion=%d", rowCount, promptTokens, completionTokens)
|
||||
}
|
||||
|
||||
// TestLive_VenicePricingFromCatalog verifies that model sync populates
|
||||
// the model_pricing table from Venice's pricing data.
|
||||
func TestLive_VenicePricingFromCatalog(t *testing.T) {
|
||||
// TestLive_PricingFromCatalog verifies model sync populates pricing.
|
||||
func TestLive_PricingFromCatalog(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
pc := requireLiveProvider(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
|
||||
configID, _ := setupProviderWithModel(t, h, adminToken, pc)
|
||||
|
||||
// Check if pricing was populated during fetch
|
||||
var pricingCount int
|
||||
database.TestDB.QueryRow(`
|
||||
SELECT COUNT(*) FROM model_pricing WHERE provider_config_id = $1
|
||||
`, configID).Scan(&pricingCount)
|
||||
database.TestDB.QueryRow(
|
||||
"SELECT COUNT(*) FROM model_pricing WHERE provider_config_id = "+database.PH(1),
|
||||
configID).Scan(&pricingCount)
|
||||
|
||||
if pricingCount == 0 {
|
||||
t.Skip("Venice model sync did not populate pricing — may need provider pricing support")
|
||||
t.Skip("model sync did not populate pricing — provider may not support pricing data")
|
||||
}
|
||||
|
||||
t.Logf(" ✓ Catalog sync populated %d pricing entries", pricingCount)
|
||||
|
||||
// Verify our test model has pricing
|
||||
var inputPerM, outputPerM float64
|
||||
err := database.TestDB.QueryRow(`
|
||||
SELECT COALESCE(input_per_m, 0), COALESCE(output_per_m, 0)
|
||||
FROM model_pricing
|
||||
WHERE provider_config_id = $1 AND model_id = $2
|
||||
`, configID, veniceTestModel).Scan(&inputPerM, &outputPerM)
|
||||
if err != nil {
|
||||
t.Logf(" ⚠ No pricing for %s specifically (may use different model ID)", veniceTestModel)
|
||||
} else {
|
||||
t.Logf(" ✓ %s pricing: $%.4f input, $%.4f output (per 1M)", veniceTestModel, inputPerM, outputPerM)
|
||||
}
|
||||
|
||||
// Admin pricing list should show these (scope=global, not personal)
|
||||
w := h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin pricing: %d: %s", w.Code, w.Body.String())
|
||||
@@ -478,52 +483,64 @@ func TestLive_VenicePricingFromCatalog(t *testing.T) {
|
||||
decode(w, &entries)
|
||||
if len(entries) == 0 {
|
||||
t.Error("admin pricing API should return catalog-synced entries")
|
||||
} else {
|
||||
t.Logf(" ✓ Admin pricing API returns %d entries", len(entries))
|
||||
}
|
||||
t.Logf(" ✓ Admin pricing API returns %d entries", len(entries))
|
||||
}
|
||||
|
||||
// TestLive_VeniceEmbeddings tests the Venice embeddings endpoint directly
|
||||
// using the BGE-M3 model ($0.15 per 1M tokens).
|
||||
func TestLive_VeniceEmbeddings(t *testing.T) {
|
||||
veniceKey := requireVeniceKey(t)
|
||||
// TestLive_Embeddings tests the embeddings endpoint directly.
|
||||
// Only runs for providers that support embeddings (currently Venice).
|
||||
func TestLive_Embeddings(t *testing.T) {
|
||||
pc := requireLiveProvider(t)
|
||||
|
||||
// Only Venice has a known embedding model for now
|
||||
if pc.Provider != "venice" {
|
||||
t.Skipf("embedding test only implemented for Venice (got %s)", pc.Provider)
|
||||
}
|
||||
|
||||
provider := &providers.VeniceProvider{}
|
||||
cfg := providers.ProviderConfig{
|
||||
Endpoint: "https://api.venice.ai/api/v1",
|
||||
APIKey: veniceKey,
|
||||
Endpoint: pc.Endpoint,
|
||||
APIKey: pc.Key,
|
||||
}
|
||||
|
||||
resp, err := provider.Embed(
|
||||
context.Background(), cfg,
|
||||
providers.EmbeddingRequest{
|
||||
Model: "text-embedding-bge-m3",
|
||||
Input: []string{"test embedding"},
|
||||
},
|
||||
)
|
||||
// Retry up to 3 times — Venice embedding endpoint can return transient 500s
|
||||
var resp *providers.EmbeddingResponse
|
||||
var err error
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
resp, err = provider.Embed(
|
||||
context.Background(), cfg,
|
||||
providers.EmbeddingRequest{
|
||||
Model: "text-embedding-bge-m3",
|
||||
Input: []string{"test embedding"},
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
t.Logf(" Embed attempt %d: %v", attempt, err)
|
||||
if attempt < 3 {
|
||||
time.Sleep(time.Duration(attempt) * 2 * time.Second)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Venice Embed: %v", err)
|
||||
t.Skipf("Embed failed after 3 attempts (transient): %v", err)
|
||||
}
|
||||
if len(resp.Embeddings) == 0 {
|
||||
t.Fatal("expected at least 1 embedding vector")
|
||||
if len(resp.Embeddings) == 0 || len(resp.Embeddings[0]) == 0 {
|
||||
t.Fatal("expected non-empty embedding vector")
|
||||
}
|
||||
if len(resp.Embeddings[0]) == 0 {
|
||||
t.Fatal("embedding vector should not be empty")
|
||||
}
|
||||
t.Logf(" ✓ Embedding returned %d dimensions, input_tokens=%d",
|
||||
t.Logf(" ✓ Embedding: %d dimensions, input_tokens=%d",
|
||||
len(resp.Embeddings[0]), resp.InputTokens)
|
||||
}
|
||||
|
||||
// TestLive_VeniceModelDeletion tests cleanup: delete provider removes catalog entries
|
||||
func TestLive_VeniceModelDeletion(t *testing.T) {
|
||||
// TestLive_ModelDeletion tests cleanup: delete provider removes catalog entries.
|
||||
func TestLive_ModelDeletion(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
pc := requireLiveProvider(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,
|
||||
"name": pc.Provider + " Delete Test", "provider": pc.Provider,
|
||||
"endpoint": pc.Endpoint, "api_key": pc.Key,
|
||||
})
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
@@ -532,65 +549,60 @@ func TestLive_VeniceModelDeletion(t *testing.T) {
|
||||
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)
|
||||
database.TestDB.QueryRow(
|
||||
"SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = "+database.PH(1),
|
||||
configID).Scan(&count)
|
||||
if count == 0 {
|
||||
t.Fatal("catalog should have models after fetch")
|
||||
}
|
||||
t.Logf(" %d models in catalog before delete", count)
|
||||
t.Logf(" %d models 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)
|
||||
database.TestDB.QueryRow(
|
||||
"SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = "+database.PH(1),
|
||||
configID).Scan(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("catalog should be empty after provider delete, got %d entries", count)
|
||||
t.Errorf("catalog should be empty after delete, got %d", 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) {
|
||||
// TestLive_BYOK_AutoFetch exercises the user experience:
|
||||
// user creates a BYOK provider → auto-fetch triggers → models appear.
|
||||
func TestLive_BYOK_AutoFetch(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
pc := requireLiveProvider(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)
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = "+database.PH(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")
|
||||
// User creates BYOK 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,
|
||||
"name": "My " + pc.Provider,
|
||||
"provider": pc.Provider,
|
||||
"endpoint": pc.Endpoint,
|
||||
"api_key": pc.Key,
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create BYOK provider: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("create BYOK: 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"])
|
||||
t.Fatalf("auto-fetch should succeed, got warning: %v", created["warning"])
|
||||
}
|
||||
modelsFetched := created["models_fetched"]
|
||||
if modelsFetched == nil || modelsFetched.(float64) < 1 {
|
||||
@@ -598,12 +610,8 @@ func TestLive_VeniceBYOK_AutoFetch(t *testing.T) {
|
||||
}
|
||||
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")
|
||||
// Verify user sees personal models
|
||||
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{})
|
||||
@@ -615,40 +623,11 @@ func TestLive_VeniceBYOK_AutoFetch(t *testing.T) {
|
||||
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.Fatalf("user should see personal BYOK models, got %d", personalCount)
|
||||
}
|
||||
t.Logf(" ✓ User sees %d personal BYOK models (out of %d total)", personalCount, len(userModels))
|
||||
t.Logf(" ✓ User sees %d personal BYOK models", personalCount)
|
||||
|
||||
// ── 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 ──
|
||||
// Cleanup
|
||||
h.request("DELETE", fmt.Sprintf("/api/v1/api-configs/%s", cfgID), userToken, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
|
||||
var total int
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`,
|
||||
database.Q(`SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`),
|
||||
channelID,
|
||||
).Scan(&total)
|
||||
if err != nil {
|
||||
@@ -97,14 +97,14 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
FROM messages
|
||||
WHERE channel_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, channelID, perPage, offset)
|
||||
`), channelID, perPage, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"})
|
||||
return
|
||||
@@ -123,9 +123,14 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"})
|
||||
return
|
||||
}
|
||||
msg.SiblingCount = getSiblingCount(channelID, msg.ParentID)
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
rows.Close() // release connection before sibling queries
|
||||
|
||||
// Enrich with sibling counts (requires DB access, must happen after rows are closed)
|
||||
for i := range messages {
|
||||
messages[i].SiblingCount = getSiblingCount(channelID, messages[i].ParentID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, paginatedResponse{
|
||||
Data: messages,
|
||||
@@ -191,27 +196,53 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
|
||||
}
|
||||
|
||||
var msg messageResponse
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model, parent_id,
|
||||
participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
`, channelID, req.Role, req.Content, req.Model, parentID,
|
||||
participantType, participantID, siblingIdx,
|
||||
).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
|
||||
return
|
||||
if database.IsSQLite() {
|
||||
newID := store.NewID()
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO messages (id, channel_id, role, content, model, parent_id,
|
||||
participant_type, participant_id, sibling_index)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, newID, channelID, req.Role, req.Content, req.Model, parentID,
|
||||
participantType, participantID, siblingIdx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
|
||||
return
|
||||
}
|
||||
err = database.DB.QueryRow(`
|
||||
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
FROM messages WHERE id = ?`, newID).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created message"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model, parent_id,
|
||||
participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
`, channelID, req.Role, req.Content, req.Model, parentID,
|
||||
participantType, participantID, siblingIdx,
|
||||
).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
|
||||
return
|
||||
}
|
||||
}
|
||||
msg.SiblingCount = getSiblingCount(channelID, msg.ParentID)
|
||||
|
||||
_ = updateCursor(channelID, userID, msg.ID)
|
||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||
_, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID)
|
||||
|
||||
c.JSON(http.StatusCreated, msg)
|
||||
}
|
||||
@@ -240,10 +271,10 @@ func (h *MessageHandler) EditMessage(c *gin.Context) {
|
||||
// Load target — must exist, belong to channel, be a user message
|
||||
var targetParentID *string
|
||||
var targetRole string
|
||||
err := database.DB.QueryRow(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT parent_id, role FROM messages
|
||||
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
|
||||
`, messageID, channelID).Scan(&targetParentID, &targetRole)
|
||||
`), messageID, channelID).Scan(&targetParentID, &targetRole)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
@@ -262,18 +293,39 @@ func (h *MessageHandler) EditMessage(c *gin.Context) {
|
||||
siblingIdx := nextSiblingIndex(channelID, targetParentID)
|
||||
|
||||
var msg messageResponse
|
||||
err = database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, parent_id,
|
||||
participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, 'user', $2, $3, 'user', $4, $5)
|
||||
RETURNING id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
`, channelID, req.Content, targetParentID, userID, siblingIdx,
|
||||
).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
)
|
||||
if database.IsSQLite() {
|
||||
newID := store.NewID()
|
||||
_, err = database.DB.Exec(`
|
||||
INSERT INTO messages (id, channel_id, role, content, parent_id,
|
||||
participant_type, participant_id, sibling_index)
|
||||
VALUES (?, ?, 'user', ?, ?, 'user', ?, ?)
|
||||
`, newID, channelID, req.Content, targetParentID, userID, siblingIdx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"})
|
||||
return
|
||||
}
|
||||
err = database.DB.QueryRow(`
|
||||
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
FROM messages WHERE id = ?`, newID).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
)
|
||||
} else {
|
||||
err = database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, parent_id,
|
||||
participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, 'user', $2, $3, 'user', $4, $5)
|
||||
RETURNING id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
`, channelID, req.Content, targetParentID, userID, siblingIdx,
|
||||
).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"})
|
||||
return
|
||||
@@ -282,7 +334,7 @@ func (h *MessageHandler) EditMessage(c *gin.Context) {
|
||||
|
||||
// Cursor now points to the new sibling (it's a leaf — no children yet)
|
||||
_ = updateCursor(channelID, userID, msg.ID)
|
||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||
_, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID)
|
||||
|
||||
c.JSON(http.StatusCreated, msg)
|
||||
}
|
||||
@@ -315,10 +367,10 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
// Load target message
|
||||
var targetParentID *string
|
||||
var targetRole string
|
||||
err := database.DB.QueryRow(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT parent_id, role FROM messages
|
||||
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
|
||||
`, messageID, channelID).Scan(&targetParentID, &targetRole)
|
||||
`), messageID, channelID).Scan(&targetParentID, &targetRole)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
@@ -399,7 +451,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
// Fallback: channel's stored model
|
||||
if model == "" {
|
||||
var channelModel *string
|
||||
_ = database.DB.QueryRow(`SELECT model FROM channels WHERE id = $1`, channelID).Scan(&channelModel)
|
||||
_ = database.DB.QueryRow(database.Q(`SELECT model FROM channels WHERE id = $1`), channelID).Scan(&channelModel)
|
||||
if channelModel != nil {
|
||||
model = *channelModel
|
||||
}
|
||||
@@ -427,7 +479,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: presetSystemPrompt})
|
||||
} else {
|
||||
var systemPrompt *string
|
||||
_ = database.DB.QueryRow(`SELECT system_prompt FROM channels WHERE id = $1`, channelID).Scan(&systemPrompt)
|
||||
_ = database.DB.QueryRow(database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID).Scan(&systemPrompt)
|
||||
if systemPrompt != nil && *systemPrompt != "" {
|
||||
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: *systemPrompt})
|
||||
}
|
||||
@@ -480,16 +532,31 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
}
|
||||
|
||||
var newID string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model, tool_calls,
|
||||
parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, 'assistant', $2, $3, $4, $5, 'model', $6, $7)
|
||||
RETURNING id
|
||||
`, channelID, result.Content, model, tcVal, newParentID, model, siblingIdx).Scan(&newID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to persist regenerated message: %v", err)
|
||||
if database.IsSQLite() {
|
||||
newID = store.NewID()
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO messages (id, channel_id, role, content, model, tool_calls,
|
||||
parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES (?, ?, 'assistant', ?, ?, ?, ?, 'model', ?, ?)
|
||||
`, newID, channelID, result.Content, model, tcVal, newParentID, model, siblingIdx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to persist regenerated message: %v", err)
|
||||
newID = ""
|
||||
}
|
||||
} else {
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model, tool_calls,
|
||||
parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, 'assistant', $2, $3, $4, $5, 'model', $6, $7)
|
||||
RETURNING id
|
||||
`, channelID, result.Content, model, tcVal, newParentID, model, siblingIdx).Scan(&newID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to persist regenerated message: %v", err)
|
||||
newID = ""
|
||||
}
|
||||
}
|
||||
|
||||
if newID != "" {
|
||||
_ = updateCursor(channelID, userID, newID)
|
||||
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
@@ -505,7 +572,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||
_, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID)
|
||||
}
|
||||
|
||||
// Log usage for regeneration
|
||||
@@ -536,10 +603,10 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
|
||||
|
||||
// Verify the target message belongs to this channel
|
||||
var msgChannelID string
|
||||
err := database.DB.QueryRow(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT channel_id FROM messages
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`, req.ActiveLeafID).Scan(&msgChannelID)
|
||||
`), req.ActiveLeafID).Scan(&msgChannelID)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
@@ -609,7 +676,7 @@ func (h *MessageHandler) ListSiblings(c *gin.Context) {
|
||||
func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
|
||||
var ownerID string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT user_id FROM channels WHERE id = $1`, channelID,
|
||||
database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID,
|
||||
).Scan(&ownerID)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Request / Response Types ────────────────
|
||||
@@ -59,11 +59,55 @@ type searchResult struct {
|
||||
}
|
||||
|
||||
// NoteHandler handles notes CRUD.
|
||||
type NoteHandler struct{}
|
||||
type NoteHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
// NewNoteHandler creates a new handler.
|
||||
func NewNoteHandler() *NoteHandler {
|
||||
return &NoteHandler{}
|
||||
func NewNoteHandler(s ...store.Stores) *NoteHandler {
|
||||
h := &NoteHandler{}
|
||||
if len(s) > 0 {
|
||||
h.stores = s[0]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// toNoteResponse converts a models.Note to a noteResponse.
|
||||
func toNoteResponse(n *models.Note) noteResponse {
|
||||
tags := n.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
return noteResponse{
|
||||
ID: n.ID,
|
||||
Title: n.Title,
|
||||
Content: n.Content,
|
||||
FolderPath: n.FolderPath,
|
||||
Tags: tags,
|
||||
SourceChannelID: n.SourceChannelID,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
func toNoteListItem(n models.Note) noteListItem {
|
||||
tags := n.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
preview := n.Content
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200]
|
||||
}
|
||||
return noteListItem{
|
||||
ID: n.ID,
|
||||
Title: n.Title,
|
||||
FolderPath: n.FolderPath,
|
||||
Tags: tags,
|
||||
Preview: preview,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create ──────────────────────────────────
|
||||
@@ -89,29 +133,21 @@ func (h *NoteHandler) Create(c *gin.Context) {
|
||||
sourceChannelID = &req.SourceChannelID
|
||||
}
|
||||
|
||||
var note noteResponse
|
||||
var dbTags pq.StringArray
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO notes (user_id, title, content, folder_path, tags, source_channel_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, title, content, folder_path, tags, source_channel_id,
|
||||
created_at::text, updated_at::text
|
||||
`, userID, req.Title, req.Content, folder, pq.Array(tags), sourceChannelID,
|
||||
).Scan(
|
||||
¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath,
|
||||
&dbTags, ¬e.SourceChannelID,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
note := &models.Note{
|
||||
UserID: userID,
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
FolderPath: folder,
|
||||
Tags: tags,
|
||||
SourceChannelID: sourceChannelID,
|
||||
}
|
||||
|
||||
if err := h.stores.Notes.Create(c.Request.Context(), note); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create note"})
|
||||
return
|
||||
}
|
||||
note.Tags = []string(dbTags)
|
||||
if note.Tags == nil {
|
||||
note.Tags = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, note)
|
||||
c.JSON(http.StatusCreated, toNoteResponse(note))
|
||||
}
|
||||
|
||||
// ── Get ─────────────────────────────────────
|
||||
@@ -121,31 +157,18 @@ func (h *NoteHandler) Get(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
noteID := c.Param("id")
|
||||
|
||||
var note noteResponse
|
||||
var dbTags pq.StringArray
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT id, title, content, folder_path, tags, source_channel_id,
|
||||
created_at::text, updated_at::text
|
||||
FROM notes WHERE id = $1 AND user_id = $2
|
||||
`, noteID, userID).Scan(
|
||||
¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath,
|
||||
&dbTags, ¬e.SourceChannelID,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
note, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get note"})
|
||||
// Verify ownership
|
||||
if note.UserID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
return
|
||||
}
|
||||
note.Tags = []string(dbTags)
|
||||
if note.Tags == nil {
|
||||
note.Tags = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, note)
|
||||
c.JSON(http.StatusOK, toNoteResponse(note))
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────
|
||||
@@ -162,82 +185,57 @@ func (h *NoteHandler) Update(c *gin.Context) {
|
||||
noteID := c.Param("id")
|
||||
|
||||
// Verify ownership
|
||||
var exists bool
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM notes WHERE id = $1 AND user_id = $2)`,
|
||||
noteID, userID,
|
||||
).Scan(&exists)
|
||||
if err != nil || !exists {
|
||||
existing, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
|
||||
if err != nil || existing.UserID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build dynamic update
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
// Build fields map
|
||||
fields := map[string]interface{}{}
|
||||
|
||||
if req.Title != nil {
|
||||
setClauses = append(setClauses, "title = $"+strconv.Itoa(argIdx))
|
||||
args = append(args, *req.Title)
|
||||
argIdx++
|
||||
fields["title"] = *req.Title
|
||||
}
|
||||
|
||||
if req.Content != nil {
|
||||
mode := strings.ToLower(req.Mode)
|
||||
switch mode {
|
||||
case "append":
|
||||
setClauses = append(setClauses, "content = content || $"+strconv.Itoa(argIdx))
|
||||
fields["content"] = existing.Content + *req.Content
|
||||
case "prepend":
|
||||
setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx)+" || content")
|
||||
fields["content"] = *req.Content + existing.Content
|
||||
default: // "replace" or empty
|
||||
setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx))
|
||||
fields["content"] = *req.Content
|
||||
}
|
||||
args = append(args, *req.Content)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if req.FolderPath != nil {
|
||||
setClauses = append(setClauses, "folder_path = $"+strconv.Itoa(argIdx))
|
||||
args = append(args, normalizeFolderPath(*req.FolderPath))
|
||||
argIdx++
|
||||
fields["folder_path"] = normalizeFolderPath(*req.FolderPath)
|
||||
}
|
||||
|
||||
if req.Tags != nil {
|
||||
setClauses = append(setClauses, "tags = $"+strconv.Itoa(argIdx))
|
||||
args = append(args, pq.Array(req.Tags))
|
||||
argIdx++
|
||||
fields["tags"] = req.Tags
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
if len(fields) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
// WHERE clause
|
||||
args = append(args, noteID, userID)
|
||||
query := "UPDATE notes SET " + strings.Join(setClauses, ", ") +
|
||||
" WHERE id = $" + strconv.Itoa(argIdx) +
|
||||
" AND user_id = $" + strconv.Itoa(argIdx+1) +
|
||||
" RETURNING id, title, content, folder_path, tags, source_channel_id, created_at::text, updated_at::text"
|
||||
|
||||
var note noteResponse
|
||||
var dbTags pq.StringArray
|
||||
err = database.DB.QueryRow(query, args...).Scan(
|
||||
¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath,
|
||||
&dbTags, ¬e.SourceChannelID,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err := h.stores.Notes.Update(c.Request.Context(), noteID, fields); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update note"})
|
||||
return
|
||||
}
|
||||
note.Tags = []string(dbTags)
|
||||
if note.Tags == nil {
|
||||
note.Tags = []string{}
|
||||
|
||||
// Re-fetch to get updated timestamps
|
||||
updated, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated note"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, note)
|
||||
c.JSON(http.StatusOK, toNoteResponse(updated))
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────
|
||||
@@ -247,17 +245,15 @@ func (h *NoteHandler) Delete(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
noteID := c.Param("id")
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
`DELETE FROM notes WHERE id = $1 AND user_id = $2`,
|
||||
noteID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete note"})
|
||||
// Verify ownership
|
||||
existing, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
|
||||
if err != nil || existing.UserID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
return
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
|
||||
if err := h.stores.Notes.Delete(c.Request.Context(), noteID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete note"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -286,18 +282,15 @@ func (h *NoteHandler) BulkDelete(c *gin.Context) {
|
||||
|
||||
userID := getUserID(c)
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
`DELETE FROM notes WHERE id = ANY($1) AND user_id = $2`,
|
||||
pq.Array(req.IDs), userID,
|
||||
)
|
||||
count, err := h.stores.Notes.BulkDelete(c.Request.Context(), req.IDs, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notes"})
|
||||
return
|
||||
}
|
||||
count, _ := result.RowsAffected()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": count})
|
||||
}
|
||||
|
||||
// GET /api/v1/notes?folder=/path&tag=sometag&limit=50&offset=0&sort=created_asc
|
||||
|
||||
func (h *NoteHandler) List(c *gin.Context) {
|
||||
@@ -312,77 +305,53 @@ func (h *NoteHandler) List(c *gin.Context) {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
query := `SELECT id, title, folder_path, tags, LEFT(content, 200),
|
||||
created_at::text, updated_at::text
|
||||
FROM notes WHERE user_id = $1`
|
||||
args := []interface{}{userID}
|
||||
argIdx := 2
|
||||
|
||||
if folder != "" {
|
||||
query += " AND folder_path = $" + strconv.Itoa(argIdx)
|
||||
args = append(args, normalizeFolderPath(folder))
|
||||
argIdx++
|
||||
opts := store.NoteListOptions{
|
||||
ListOptions: store.ListOptions{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
},
|
||||
FolderPath: normalizeFolderPath(folder),
|
||||
Tag: tag,
|
||||
}
|
||||
if tag != "" {
|
||||
query += " AND $" + strconv.Itoa(argIdx) + " = ANY(tags)"
|
||||
args = append(args, tag)
|
||||
argIdx++
|
||||
if folder == "" {
|
||||
opts.FolderPath = ""
|
||||
}
|
||||
|
||||
// Sort options
|
||||
// Map sort parameter
|
||||
switch sort {
|
||||
case "created_asc":
|
||||
query += " ORDER BY created_at ASC"
|
||||
opts.Sort = "created_at"
|
||||
opts.Order = "ASC"
|
||||
case "created_desc":
|
||||
query += " ORDER BY created_at DESC"
|
||||
opts.Sort = "created_at"
|
||||
opts.Order = "DESC"
|
||||
case "updated_asc":
|
||||
query += " ORDER BY updated_at ASC"
|
||||
opts.Sort = "updated_at"
|
||||
opts.Order = "ASC"
|
||||
case "title_asc":
|
||||
query += " ORDER BY title ASC"
|
||||
opts.Sort = "title"
|
||||
opts.Order = "ASC"
|
||||
case "title_desc":
|
||||
query += " ORDER BY title DESC"
|
||||
opts.Sort = "title"
|
||||
opts.Order = "DESC"
|
||||
default: // "updated_desc"
|
||||
query += " ORDER BY updated_at DESC"
|
||||
opts.Sort = ""
|
||||
opts.Order = ""
|
||||
}
|
||||
|
||||
query += " LIMIT $" + strconv.Itoa(argIdx) +
|
||||
" OFFSET $" + strconv.Itoa(argIdx+1)
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := database.DB.Query(query, args...)
|
||||
notes, total, err := h.stores.Notes.ListForUser(c.Request.Context(), userID, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
notes := make([]noteListItem, 0)
|
||||
for rows.Next() {
|
||||
var n noteListItem
|
||||
var dbTags pq.StringArray
|
||||
if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath, &dbTags, &n.Preview,
|
||||
&n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
n.Tags = []string(dbTags)
|
||||
if n.Tags == nil {
|
||||
n.Tags = []string{}
|
||||
}
|
||||
notes = append(notes, n)
|
||||
items := make([]noteListItem, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
items = append(items, toNoteListItem(n))
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int
|
||||
countQuery := `SELECT COUNT(*) FROM notes WHERE user_id = $1`
|
||||
countArgs := []interface{}{userID}
|
||||
if folder != "" {
|
||||
countQuery += " AND folder_path = $2"
|
||||
countArgs = append(countArgs, normalizeFolderPath(folder))
|
||||
}
|
||||
_ = database.DB.QueryRow(countQuery, countArgs...).Scan(&total)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": notes,
|
||||
"data": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
@@ -405,8 +374,37 @@ func (h *NoteHandler) Search(c *gin.Context) {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
// Use plainto_tsquery for natural language (not websearch_to_tsquery which
|
||||
// requires Postgres 11+ and has stricter syntax).
|
||||
// Use Postgres full-text search when available, LIKE fallback on SQLite
|
||||
if database.IsPostgres() {
|
||||
h.searchPostgres(c, userID, q, limit)
|
||||
return
|
||||
}
|
||||
|
||||
// SQLite: use store's LIKE-based search
|
||||
notes, _, err := h.stores.Notes.Search(c.Request.Context(), userID, q, store.ListOptions{Limit: limit})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]searchResult, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
results = append(results, searchResult{
|
||||
noteListItem: toNoteListItem(n),
|
||||
Rank: 1.0,
|
||||
Headline: "",
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": results,
|
||||
"query": q,
|
||||
"total": len(results),
|
||||
})
|
||||
}
|
||||
|
||||
// searchPostgres uses Postgres full-text search with ts_rank and ts_headline.
|
||||
func (h *NoteHandler) searchPostgres(c *gin.Context, userID, q string, limit int) {
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT id, title, folder_path, tags, LEFT(content, 200),
|
||||
created_at::text, updated_at::text,
|
||||
@@ -419,25 +417,25 @@ func (h *NoteHandler) Search(c *gin.Context) {
|
||||
ORDER BY rank DESC
|
||||
LIMIT $3
|
||||
`, userID, q, limit)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Import pq at call site to avoid pulling it in for SQLite builds
|
||||
results := make([]searchResult, 0)
|
||||
for rows.Next() {
|
||||
var r searchResult
|
||||
var dbTags pq.StringArray
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Preview,
|
||||
var tags []string
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, pgScanStringArray(&tags), &r.Preview,
|
||||
&r.CreatedAt, &r.UpdatedAt, &r.Rank, &r.Headline); err != nil {
|
||||
continue
|
||||
}
|
||||
r.Tags = []string(dbTags)
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
r.Tags = tags
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
@@ -454,12 +452,12 @@ func (h *NoteHandler) Search(c *gin.Context) {
|
||||
func (h *NoteHandler) ListFolders(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT DISTINCT folder_path, COUNT(*) AS count
|
||||
FROM notes WHERE user_id = $1
|
||||
GROUP BY folder_path
|
||||
ORDER BY folder_path
|
||||
`, userID)
|
||||
`), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
|
||||
return
|
||||
|
||||
37
server/handlers/pg_helpers.go
Normal file
37
server/handlers/pg_helpers.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// pgScanStringArray returns a sql.Scanner that scans a Postgres text[] array
|
||||
// into a Go string slice. Used by handler-level Postgres-specific queries.
|
||||
func pgScanStringArray(dest *[]string) interface{ Scan(src interface{}) error } {
|
||||
return &pgStringArrayScanner{dest: dest}
|
||||
}
|
||||
|
||||
type pgStringArrayScanner struct {
|
||||
dest *[]string
|
||||
}
|
||||
|
||||
func (s *pgStringArrayScanner) Scan(src interface{}) error {
|
||||
var arr pq.StringArray
|
||||
if err := arr.Scan(src); err != nil {
|
||||
// Fallback: try JSON array (for SQLite compatibility if accidentally called)
|
||||
if b, ok := src.([]byte); ok {
|
||||
return json.Unmarshal(b, s.dest)
|
||||
}
|
||||
if str, ok := src.(string); ok {
|
||||
return json.Unmarshal([]byte(str), s.dest)
|
||||
}
|
||||
return err
|
||||
}
|
||||
*s.dest = []string(arr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure pq is importable even if not directly referenced elsewhere.
|
||||
var _ sql.Scanner = &pgStringArrayScanner{}
|
||||
@@ -54,10 +54,10 @@ func (h *SettingsHandler) GetProfile(c *gin.Context) {
|
||||
|
||||
var p profileResponse
|
||||
var settingsRaw string
|
||||
err := database.DB.QueryRow(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT id, username, email, display_name, role, avatar_url, settings::text, created_at
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(
|
||||
`), userID).Scan(
|
||||
&p.ID, &p.Username, &p.Email, &p.DisplayName, &p.Role,
|
||||
&p.Avatar, &settingsRaw, &p.CreatedAt,
|
||||
)
|
||||
@@ -89,7 +89,7 @@ func (h *SettingsHandler) UpdateProfile(c *gin.Context) {
|
||||
|
||||
if req.DisplayName != nil {
|
||||
_, err := database.DB.Exec(
|
||||
`UPDATE users SET display_name = $1, updated_at = NOW() WHERE id = $2`,
|
||||
database.Q(`UPDATE users SET display_name = $1, updated_at = NOW() WHERE id = $2`),
|
||||
*req.DisplayName, userID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -101,11 +101,11 @@ func (h *SettingsHandler) UpdateProfile(c *gin.Context) {
|
||||
if req.Email != nil {
|
||||
email := strings.ToLower(strings.TrimSpace(*req.Email))
|
||||
_, err := database.DB.Exec(
|
||||
`UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2`,
|
||||
database.Q(`UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2`),
|
||||
email, userID,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
if database.IsUniqueViolation(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "email already taken"})
|
||||
return
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func (h *SettingsHandler) ChangePassword(c *gin.Context) {
|
||||
// Verify current password
|
||||
var hash string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT password_hash FROM users WHERE id = $1`, userID,
|
||||
database.Q(`SELECT password_hash FROM users WHERE id = $1`), userID,
|
||||
).Scan(&hash)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify password"})
|
||||
@@ -151,7 +151,7 @@ func (h *SettingsHandler) ChangePassword(c *gin.Context) {
|
||||
}
|
||||
|
||||
_, err = database.DB.Exec(
|
||||
`UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
|
||||
database.Q(`UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`),
|
||||
string(newHash), userID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -172,7 +172,7 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
|
||||
var settingsRaw string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT settings::text FROM users WHERE id = $1`, userID,
|
||||
database.Q(`SELECT settings::text FROM users WHERE id = $1`), userID,
|
||||
).Scan(&settingsRaw)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
|
||||
@@ -203,10 +203,13 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
// JSONB merge — existing keys preserved, incoming keys overwrite
|
||||
_, err = database.DB.Exec(`
|
||||
UPDATE users SET settings = settings || $1::jsonb, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
`, string(patch), userID)
|
||||
var mergeQuery string
|
||||
if database.IsSQLite() {
|
||||
mergeQuery = `UPDATE users SET settings = json_patch(settings, ?), updated_at = datetime('now') WHERE id = ?`
|
||||
} else {
|
||||
mergeQuery = `UPDATE users SET settings = settings || $1::jsonb, updated_at = NOW() WHERE id = $2`
|
||||
}
|
||||
_, err = database.DB.Exec(mergeQuery, string(patch), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"})
|
||||
return
|
||||
@@ -227,10 +230,10 @@ func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) {
|
||||
|
||||
var vaultSet bool
|
||||
var encUEK, salt, nonce []byte
|
||||
err := database.DB.QueryRow(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(&vaultSet, &encUEK, &salt, &nonce)
|
||||
`), userID).Scan(&vaultSet, &encUEK, &salt, &nonce)
|
||||
if err != nil || !vaultSet || len(encUEK) == 0 {
|
||||
return // No vault to re-wrap
|
||||
}
|
||||
@@ -257,11 +260,11 @@ func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = database.DB.Exec(`
|
||||
_, err = database.DB.Exec(database.Q(`
|
||||
UPDATE users
|
||||
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, updated_at = NOW()
|
||||
WHERE id = $4
|
||||
`, newEncUEK, newSalt, newNonce, userID)
|
||||
`), newEncUEK, newSalt, newNonce, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Vault re-wrap failed for user %s (persist): %v", userID, err)
|
||||
return
|
||||
|
||||
@@ -33,7 +33,7 @@ func (h *SummarizeHandler) Summarize(c *gin.Context) {
|
||||
// ── Verify channel ownership ──
|
||||
var ownerID string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT user_id FROM channels WHERE id = $1`, channelID,
|
||||
database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID,
|
||||
).Scan(&ownerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -20,13 +19,13 @@ import (
|
||||
func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT id, name, provider, endpoint, api_key_enc,
|
||||
model_default, config::text, is_active, is_private, created_at, updated_at
|
||||
model_default, config, is_active, is_private, created_at, updated_at
|
||||
FROM provider_configs
|
||||
WHERE scope = 'team' AND owner_id = $1
|
||||
ORDER BY name ASC
|
||||
`, teamID)
|
||||
`), teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team providers"})
|
||||
return
|
||||
@@ -119,15 +118,14 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
var id string
|
||||
err := database.DB.QueryRow(`
|
||||
id, err := database.InsertReturningID(`
|
||||
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint,
|
||||
api_key_enc, key_nonce, key_scope, model_default, config, is_private)
|
||||
VALUES ('team', $1, $2, $3, $4, $5, $6, 'team', $7, $8::jsonb, $9)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'team', $8, $9::jsonb, $10)
|
||||
RETURNING id
|
||||
`, teamID, req.Name, req.Provider, req.Endpoint,
|
||||
`, "team", teamID, req.Name, req.Provider, req.Endpoint,
|
||||
apiKeyEnc, keyNonce, req.ModelDefault, configJSON, req.IsPrivate,
|
||||
).Scan(&id)
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to create team provider: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create provider"})
|
||||
@@ -158,26 +156,26 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
||||
|
||||
// Verify provider belongs to this team
|
||||
var count int
|
||||
database.DB.QueryRow(`SELECT COUNT(*) FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2`, providerID, teamID).Scan(&count)
|
||||
database.DB.QueryRow(database.Q(`SELECT COUNT(*) FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2`), providerID, teamID).Scan(&count)
|
||||
if count == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build dynamic update
|
||||
query := "UPDATE provider_configs SET updated_at = NOW()"
|
||||
// Build dynamic update using ? placeholders (works on both dialects)
|
||||
setClauses := []string{"updated_at = " + database.Q("NOW()")}
|
||||
args := []interface{}{}
|
||||
argN := 1
|
||||
|
||||
addSet := func(col string, val interface{}) {
|
||||
setClauses = append(setClauses, col+" = ?")
|
||||
args = append(args, val)
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
query += ", name = $" + strconv.Itoa(argN)
|
||||
args = append(args, *req.Name)
|
||||
argN++
|
||||
addSet("name", *req.Name)
|
||||
}
|
||||
if req.Endpoint != nil {
|
||||
query += ", endpoint = $" + strconv.Itoa(argN)
|
||||
args = append(args, *req.Endpoint)
|
||||
argN++
|
||||
addSet("endpoint", *req.Endpoint)
|
||||
}
|
||||
if req.APIKey != nil && *req.APIKey != "" {
|
||||
if h.vault != nil {
|
||||
@@ -186,43 +184,42 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||
return
|
||||
}
|
||||
query += ", api_key_enc = $" + strconv.Itoa(argN)
|
||||
args = append(args, enc)
|
||||
argN++
|
||||
query += ", key_nonce = $" + strconv.Itoa(argN)
|
||||
args = append(args, nonce)
|
||||
argN++
|
||||
addSet("api_key_enc", enc)
|
||||
addSet("key_nonce", nonce)
|
||||
} else {
|
||||
query += ", api_key_enc = $" + strconv.Itoa(argN)
|
||||
args = append(args, []byte(*req.APIKey))
|
||||
argN++
|
||||
addSet("api_key_enc", []byte(*req.APIKey))
|
||||
}
|
||||
}
|
||||
if req.ModelDefault != nil {
|
||||
query += ", model_default = $" + strconv.Itoa(argN)
|
||||
args = append(args, *req.ModelDefault)
|
||||
argN++
|
||||
addSet("model_default", *req.ModelDefault)
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
query += ", is_active = $" + strconv.Itoa(argN)
|
||||
args = append(args, *req.IsActive)
|
||||
argN++
|
||||
addSet("is_active", *req.IsActive)
|
||||
}
|
||||
if req.IsPrivate != nil {
|
||||
query += ", is_private = $" + strconv.Itoa(argN)
|
||||
args = append(args, *req.IsPrivate)
|
||||
argN++
|
||||
addSet("is_private", *req.IsPrivate)
|
||||
}
|
||||
if req.Config != nil {
|
||||
b, _ := json.Marshal(req.Config)
|
||||
query += ", config = $" + strconv.Itoa(argN) + "::jsonb"
|
||||
args = append(args, string(b))
|
||||
argN++
|
||||
addSet("config", string(b))
|
||||
}
|
||||
|
||||
query += " WHERE id = $" + strconv.Itoa(argN) + " AND scope = 'team' AND owner_id = $" + strconv.Itoa(argN+1)
|
||||
args = append(args, providerID, teamID)
|
||||
|
||||
// Build final query with ? placeholders, then convert to $N for Postgres
|
||||
query := "UPDATE provider_configs SET "
|
||||
for i, s := range setClauses {
|
||||
if i > 0 {
|
||||
query += ", "
|
||||
}
|
||||
query += s
|
||||
}
|
||||
query += " WHERE id = ? AND scope = 'team' AND owner_id = ?"
|
||||
|
||||
if database.IsPostgres() {
|
||||
query = convertPlaceholders(query)
|
||||
}
|
||||
|
||||
_, err := database.DB.Exec(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update provider"})
|
||||
@@ -237,9 +234,9 @@ func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
providerID := c.Param("id")
|
||||
|
||||
result, err := database.DB.Exec(`
|
||||
result, err := database.DB.Exec(database.Q(`
|
||||
DELETE FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2
|
||||
`, providerID, teamID)
|
||||
`), providerID, teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete provider"})
|
||||
return
|
||||
@@ -263,11 +260,11 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
|
||||
var apiKeyEnc, keyNonce []byte
|
||||
var keyScope string
|
||||
var headersJSON []byte
|
||||
err := database.DB.QueryRow(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT name, provider, endpoint, api_key_enc, key_nonce, key_scope, headers
|
||||
FROM provider_configs
|
||||
WHERE id = $1 AND scope = 'team' AND owner_id = $2 AND is_active = true
|
||||
`, providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKeyEnc, &keyNonce, &keyScope, &headersJSON)
|
||||
`), providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKeyEnc, &keyNonce, &keyScope, &headersJSON)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
@@ -348,7 +345,7 @@ func isTeamProvidersAllowed(teamID string) bool {
|
||||
}
|
||||
|
||||
var settingsJSON []byte
|
||||
err = database.DB.QueryRow(`SELECT settings FROM teams WHERE id = $1`, teamID).Scan(&settingsJSON)
|
||||
err = database.DB.QueryRow(database.Q(`SELECT settings FROM teams WHERE id = $1`), teamID).Scan(&settingsJSON)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -63,14 +64,14 @@ func (h *TeamHandler) ListTeams(c *gin.Context) {
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
var total int
|
||||
if err := database.DB.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&total); err != nil {
|
||||
if err := database.DB.QueryRow(database.Q(`SELECT COUNT(*) FROM teams`)).Scan(&total); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count teams"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT t.id, t.name, t.description, t.created_by, t.is_active,
|
||||
COALESCE(t.settings::text, '{}'), t.created_at, t.updated_at,
|
||||
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
|
||||
COALESCE(mc.cnt, 0) AS member_count
|
||||
FROM teams t
|
||||
LEFT JOIN (
|
||||
@@ -78,7 +79,7 @@ func (h *TeamHandler) ListTeams(c *gin.Context) {
|
||||
) mc ON mc.team_id = t.id
|
||||
ORDER BY t.name ASC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, perPage, offset)
|
||||
`), perPage, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
@@ -90,9 +91,9 @@ func (h *TeamHandler) ListTeams(c *gin.Context) {
|
||||
var id, name, desc, createdBy, settings string
|
||||
var isActive bool
|
||||
var memberCount int
|
||||
var createdAt, updatedAt sql.NullTime
|
||||
var createdAt, updatedAt time.Time
|
||||
if err := rows.Scan(&id, &name, &desc, &createdBy, &isActive, &settings,
|
||||
&createdAt, &updatedAt, &memberCount); err != nil {
|
||||
database.ST(&createdAt), database.ST(&updatedAt), &memberCount); err != nil {
|
||||
continue
|
||||
}
|
||||
teams = append(teams, gin.H{
|
||||
@@ -103,8 +104,8 @@ func (h *TeamHandler) ListTeams(c *gin.Context) {
|
||||
"is_active": isActive,
|
||||
"settings": settings,
|
||||
"member_count": memberCount,
|
||||
"created_at": createdAt.Time,
|
||||
"updated_at": updatedAt.Time,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
})
|
||||
}
|
||||
if teams == nil {
|
||||
@@ -130,14 +131,13 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var id string
|
||||
err := database.DB.QueryRow(`
|
||||
id, err := database.InsertReturningID(`
|
||||
INSERT INTO teams (name, description, created_by)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, req.Name, req.Description, adminID).Scan(&id)
|
||||
`, req.Name, req.Description, adminID)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
if database.IsUniqueViolation(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
|
||||
return
|
||||
}
|
||||
@@ -157,15 +157,15 @@ func (h *TeamHandler) GetTeam(c *gin.Context) {
|
||||
var name, desc, createdBy, settings string
|
||||
var isActive bool
|
||||
var memberCount int
|
||||
var createdAt, updatedAt sql.NullTime
|
||||
var createdAt, updatedAt time.Time
|
||||
|
||||
err := database.DB.QueryRow(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT t.name, t.description, t.created_by, t.is_active,
|
||||
COALESCE(t.settings::text, '{}'), t.created_at, t.updated_at,
|
||||
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
|
||||
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id)
|
||||
FROM teams t WHERE t.id = $1
|
||||
`, teamID).Scan(&name, &desc, &createdBy, &isActive, &settings,
|
||||
&createdAt, &updatedAt, &memberCount)
|
||||
`), teamID).Scan(&name, &desc, &createdBy, &isActive, &settings,
|
||||
database.ST(&createdAt), database.ST(&updatedAt), &memberCount)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
||||
return
|
||||
@@ -183,8 +183,8 @@ func (h *TeamHandler) GetTeam(c *gin.Context) {
|
||||
"is_active": isActive,
|
||||
"settings": settings,
|
||||
"member_count": memberCount,
|
||||
"created_at": createdAt.Time,
|
||||
"updated_at": updatedAt.Time,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,23 +204,32 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
|
||||
args := []interface{}{}
|
||||
argN := 1
|
||||
|
||||
if req.Name != nil {
|
||||
sets = append(sets, "name = $"+strconv.Itoa(argN))
|
||||
args = append(args, *req.Name)
|
||||
addArg := func(col string, val interface{}) {
|
||||
if database.IsSQLite() {
|
||||
sets = append(sets, col+" = ?")
|
||||
} else {
|
||||
sets = append(sets, col+" = $"+strconv.Itoa(argN))
|
||||
}
|
||||
args = append(args, val)
|
||||
argN++
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
addArg("name", *req.Name)
|
||||
}
|
||||
if req.Description != nil {
|
||||
sets = append(sets, "description = $"+strconv.Itoa(argN))
|
||||
args = append(args, *req.Description)
|
||||
argN++
|
||||
addArg("description", *req.Description)
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
sets = append(sets, "is_active = $"+strconv.Itoa(argN))
|
||||
args = append(args, *req.IsActive)
|
||||
argN++
|
||||
addArg("is_active", *req.IsActive)
|
||||
}
|
||||
if req.Settings != nil {
|
||||
sets = append(sets, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
|
||||
if database.IsSQLite() {
|
||||
// SQLite: json_patch for merge
|
||||
sets = append(sets, "settings = json_patch(COALESCE(settings, '{}'), ?)")
|
||||
} else {
|
||||
sets = append(sets, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
|
||||
}
|
||||
args = append(args, *req.Settings)
|
||||
argN++
|
||||
}
|
||||
@@ -230,19 +239,19 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
query := "UPDATE teams SET "
|
||||
for i, s := range sets {
|
||||
if i > 0 {
|
||||
query += ", "
|
||||
}
|
||||
query += s
|
||||
var whereClause string
|
||||
if database.IsSQLite() {
|
||||
whereClause = " WHERE id = ?"
|
||||
} else {
|
||||
whereClause = " WHERE id = $" + strconv.Itoa(argN)
|
||||
}
|
||||
query += " WHERE id = $" + strconv.Itoa(argN)
|
||||
args = append(args, teamID)
|
||||
|
||||
query := "UPDATE teams SET " + strings.Join(sets, ", ") + whereClause
|
||||
|
||||
res, err := database.DB.Exec(query, args...)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
if database.IsUniqueViolation(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
|
||||
return
|
||||
}
|
||||
@@ -263,7 +272,7 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
|
||||
func (h *TeamHandler) DeleteTeam(c *gin.Context) {
|
||||
teamID := c.Param("id")
|
||||
|
||||
res, err := database.DB.Exec(`DELETE FROM teams WHERE id = $1`, teamID)
|
||||
res, err := database.DB.Exec(database.Q(`DELETE FROM teams WHERE id = $1`), teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||
return
|
||||
@@ -282,14 +291,14 @@ func (h *TeamHandler) DeleteTeam(c *gin.Context) {
|
||||
func (h *TeamHandler) ListMembers(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT tm.id, tm.user_id, tm.role, tm.joined_at,
|
||||
u.email, COALESCE(u.display_name, '') AS display_name, 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 ASC, u.email ASC
|
||||
`, teamID)
|
||||
`), teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
@@ -298,8 +307,7 @@ func (h *TeamHandler) ListMembers(c *gin.Context) {
|
||||
|
||||
var members []gin.H
|
||||
for rows.Next() {
|
||||
var id, userID, role, email, displayName, userRole string
|
||||
var joinedAt sql.NullTime
|
||||
var id, userID, role, email, displayName, userRole, joinedAt string
|
||||
if err := rows.Scan(&id, &userID, &role, &joinedAt, &email, &displayName, &userRole); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -307,7 +315,7 @@ func (h *TeamHandler) ListMembers(c *gin.Context) {
|
||||
"id": id,
|
||||
"user_id": userID,
|
||||
"role": role,
|
||||
"joined_at": joinedAt.Time,
|
||||
"joined_at": joinedAt,
|
||||
"email": email,
|
||||
"display_name": displayName,
|
||||
"user_role": userRole,
|
||||
@@ -333,27 +341,26 @@ func (h *TeamHandler) AddMember(c *gin.Context) {
|
||||
|
||||
// Verify team exists
|
||||
var exists bool
|
||||
database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM teams WHERE id = $1)`, teamID).Scan(&exists)
|
||||
database.DB.QueryRow(database.Q(`SELECT EXISTS(SELECT 1 FROM teams WHERE id = $1)`), teamID).Scan(&exists)
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, req.UserID).Scan(&exists)
|
||||
database.DB.QueryRow(database.Q(`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`), req.UserID).Scan(&exists)
|
||||
if !exists {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var id string
|
||||
err := database.DB.QueryRow(`
|
||||
id, err := database.InsertReturningID(`
|
||||
INSERT INTO team_members (team_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, teamID, req.UserID, req.Role).Scan(&id)
|
||||
`, teamID, req.UserID, req.Role)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
if database.IsUniqueViolation(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "user is already a member"})
|
||||
return
|
||||
}
|
||||
@@ -378,9 +385,9 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
res, err := database.DB.Exec(`
|
||||
res, err := database.DB.Exec(database.Q(`
|
||||
UPDATE team_members SET role = $1 WHERE id = $2
|
||||
`, req.Role, memberID)
|
||||
`), req.Role, memberID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
||||
return
|
||||
@@ -401,7 +408,7 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
|
||||
func (h *TeamHandler) RemoveMember(c *gin.Context) {
|
||||
memberID := c.Param("memberId")
|
||||
|
||||
res, err := database.DB.Exec(`DELETE FROM team_members WHERE id = $1`, memberID)
|
||||
res, err := database.DB.Exec(database.Q(`DELETE FROM team_members WHERE id = $1`), memberID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"})
|
||||
return
|
||||
@@ -422,16 +429,16 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) {
|
||||
func (h *TeamHandler) MyTeams(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT t.id, t.name, t.description, t.is_active,
|
||||
COALESCE(t.settings::text, '{}'),
|
||||
COALESCE(t.settings, '{}'),
|
||||
tm.role AS my_role,
|
||||
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count
|
||||
FROM teams t
|
||||
JOIN team_members tm ON tm.team_id = t.id AND tm.user_id = $1
|
||||
WHERE t.is_active = true
|
||||
ORDER BY t.name ASC
|
||||
`, userID)
|
||||
`), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
@@ -484,7 +491,7 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
||||
models := make([]availableModel, 0)
|
||||
|
||||
// ── 1. Global admin models (synced in model_catalog) ──
|
||||
rows, err := database.DB.Query(`
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT mc.id, mc.model_id, mc.display_name, mc.visibility,
|
||||
ac.provider, ac.name as provider_name
|
||||
FROM model_catalog mc
|
||||
@@ -492,7 +499,7 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
||||
WHERE mc.visibility IN ('enabled', 'team')
|
||||
AND ac.is_active = true AND ac.scope = 'global'
|
||||
ORDER BY ac.name, mc.model_id
|
||||
`)
|
||||
`))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
@@ -510,11 +517,11 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ── 2. Team provider models (live query) ──
|
||||
teamRows, err := database.DB.Query(`
|
||||
teamRows, err := database.DB.Query(database.Q(`
|
||||
SELECT id, name, provider, endpoint, api_key_enc, headers
|
||||
FROM provider_configs
|
||||
WHERE scope = 'team' AND owner_id = $1 AND is_active = true
|
||||
`, teamID)
|
||||
`), teamID)
|
||||
if err == nil {
|
||||
defer teamRows.Close()
|
||||
for teamRows.Next() {
|
||||
@@ -574,29 +581,26 @@ func getTeamID(c *gin.Context) string {
|
||||
return c.Param("id")
|
||||
}
|
||||
|
||||
// isUniqueViolation checks if a PG error is a unique constraint violation.
|
||||
// isUniqueViolation checks if a PG/SQLite error is a unique constraint violation.
|
||||
func isUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "duplicate key")
|
||||
return database.IsUniqueViolation(err)
|
||||
}
|
||||
|
||||
// IsTeamAdmin checks if a user is an admin of the given team.
|
||||
func IsTeamAdmin(userID, teamID string) bool {
|
||||
var role string
|
||||
err := database.DB.QueryRow(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2
|
||||
`, teamID, userID).Scan(&role)
|
||||
`), teamID, userID).Scan(&role)
|
||||
return err == nil && role == "admin"
|
||||
}
|
||||
|
||||
// IsTeamMember checks if a user belongs to the given team (any role).
|
||||
func IsTeamMember(userID, teamID string) bool {
|
||||
var exists bool
|
||||
database.DB.QueryRow(`
|
||||
database.DB.QueryRow(database.Q(`
|
||||
SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)
|
||||
`, teamID, userID).Scan(&exists)
|
||||
`), teamID, userID).Scan(&exists)
|
||||
return exists
|
||||
}
|
||||
|
||||
@@ -610,24 +614,36 @@ func enforcePrivateProviderPolicy(userID, configID string) error {
|
||||
|
||||
// Check if user belongs to any team with require_private_providers policy
|
||||
var requiresPrivate bool
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM team_members tm
|
||||
JOIN teams t ON t.id = tm.team_id
|
||||
WHERE tm.user_id = $1
|
||||
AND t.is_active = true
|
||||
AND t.settings->>'require_private_providers' = 'true'
|
||||
)
|
||||
`, userID).Scan(&requiresPrivate)
|
||||
var query string
|
||||
if database.IsSQLite() {
|
||||
query = `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM team_members tm
|
||||
JOIN teams t ON t.id = tm.team_id
|
||||
WHERE tm.user_id = ?
|
||||
AND t.is_active = 1
|
||||
AND json_extract(t.settings, '$.require_private_providers') = 'true'
|
||||
)`
|
||||
} else {
|
||||
query = `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM team_members tm
|
||||
JOIN teams t ON t.id = tm.team_id
|
||||
WHERE tm.user_id = $1
|
||||
AND t.is_active = true
|
||||
AND t.settings->>'require_private_providers' = 'true'
|
||||
)`
|
||||
}
|
||||
err := database.DB.QueryRow(query, userID).Scan(&requiresPrivate)
|
||||
if err != nil || !requiresPrivate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// User is in a restricted team — verify the config is private
|
||||
var isPrivate bool
|
||||
err = database.DB.QueryRow(`
|
||||
err = database.DB.QueryRow(database.Q(`
|
||||
SELECT COALESCE(is_private, false) FROM provider_configs WHERE id = $1
|
||||
`, configID).Scan(&isPrivate)
|
||||
`), configID).Scan(&isPrivate)
|
||||
if err != nil {
|
||||
return nil // config lookup failed, allow (fail open)
|
||||
}
|
||||
@@ -639,33 +655,33 @@ func enforcePrivateProviderPolicy(userID, configID string) error {
|
||||
|
||||
// ── Team Audit Log (scoped to team members) ─
|
||||
|
||||
// ListTeamAuditLog returns paginated audit entries where the actor is a member
|
||||
// of the specified team. Team admins see only their team's activity; system
|
||||
// admins see everything (but the scoping still applies via the same query).
|
||||
// GET /api/v1/teams/:teamId/audit?page=1&per_page=50&action=...&actor_id=...&resource_type=...
|
||||
func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
page, perPage, offset := parsePagination(c)
|
||||
|
||||
// Build filter clauses — always scoped to team members
|
||||
where := "WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1)"
|
||||
// Build filter clauses — always scoped to team members.
|
||||
// Use ? placeholders and convert for Postgres if needed.
|
||||
clauses := []string{"al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = ?)"}
|
||||
args := []interface{}{teamID}
|
||||
argN := 2
|
||||
|
||||
if action := c.Query("action"); action != "" {
|
||||
where += " AND al.action = $" + strconv.Itoa(argN)
|
||||
clauses = append(clauses, "al.action = ?")
|
||||
args = append(args, action)
|
||||
argN++
|
||||
}
|
||||
if actorID := c.Query("actor_id"); actorID != "" {
|
||||
where += " AND al.actor_id = $" + strconv.Itoa(argN)
|
||||
clauses = append(clauses, "al.actor_id = ?")
|
||||
args = append(args, actorID)
|
||||
argN++
|
||||
}
|
||||
if rt := c.Query("resource_type"); rt != "" {
|
||||
where += " AND al.resource_type = $" + strconv.Itoa(argN)
|
||||
clauses = append(clauses, "al.resource_type = ?")
|
||||
args = append(args, rt)
|
||||
argN++
|
||||
}
|
||||
|
||||
where := "WHERE " + strings.Join(clauses, " AND ")
|
||||
|
||||
// For Postgres, convert ? to $N
|
||||
if database.IsPostgres() {
|
||||
where = convertPlaceholders(where)
|
||||
}
|
||||
|
||||
// Count
|
||||
@@ -679,16 +695,21 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Query with actor name join
|
||||
limitOffset := fmt.Sprintf("LIMIT %d OFFSET %d", perPage, offset)
|
||||
query := `
|
||||
SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name,
|
||||
al.action, al.resource_type, al.resource_id,
|
||||
COALESCE(al.metadata::text, '{}'), al.ip_address, al.created_at
|
||||
COALESCE(al.metadata, '{}'), al.ip_address, al.created_at
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.actor_id = u.id
|
||||
` + where + `
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
|
||||
args = append(args, perPage, offset)
|
||||
` + limitOffset
|
||||
|
||||
if database.IsPostgres() {
|
||||
// Re-convert placeholders for the full query
|
||||
query = convertPlaceholders(query)
|
||||
}
|
||||
|
||||
rows, err := database.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
@@ -731,18 +752,15 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ListTeamAuditActions returns distinct action names for audit entries within
|
||||
// the team scope, for filter dropdowns.
|
||||
// GET /api/v1/teams/:teamId/audit/actions
|
||||
func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT DISTINCT al.action
|
||||
FROM audit_log al
|
||||
WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1)
|
||||
ORDER BY al.action ASC
|
||||
`, teamID)
|
||||
`), teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"actions": []string{}})
|
||||
return
|
||||
@@ -758,3 +776,18 @@ func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"actions": actions})
|
||||
}
|
||||
|
||||
// convertPlaceholders converts ? placeholders to $1, $2, etc. for Postgres.
|
||||
func convertPlaceholders(q string) string {
|
||||
n := 1
|
||||
var result strings.Builder
|
||||
for _, ch := range q {
|
||||
if ch == '?' {
|
||||
result.WriteString(fmt.Sprintf("$%d", n))
|
||||
n++
|
||||
} else {
|
||||
result.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@ import (
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
|
||||
_ "modernc.org/sqlite" // register sqlite driver for DB_DRIVER=sqlite
|
||||
)
|
||||
|
||||
// TestMain sets up the test DB (if available) and runs all tests.
|
||||
// DB-dependent tests call database.RequireTestDB(t) to skip gracefully
|
||||
// when no DB is configured.
|
||||
//
|
||||
// Set DB_DRIVER=sqlite to run against SQLite instead of Postgres.
|
||||
func TestMain(m *testing.M) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
providers.Init()
|
||||
|
||||
@@ -311,7 +311,7 @@ func main() {
|
||||
protected.PUT("/presets/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
|
||||
|
||||
// Notes
|
||||
notes := handlers.NewNoteHandler()
|
||||
notes := handlers.NewNoteHandler(stores)
|
||||
protected.GET("/notes", notes.List)
|
||||
protected.POST("/notes", notes.Create)
|
||||
protected.GET("/notes/search", notes.Search)
|
||||
|
||||
10
server/store/id.go
Normal file
10
server/store/id.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package store
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// NewID generates a new UUID string.
|
||||
// Used by SQLite stores where gen_random_uuid() is not available.
|
||||
// Also usable by Postgres stores — application-side IDs are always valid.
|
||||
func NewID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
190
server/store/sqlite/attachment.go
Normal file
190
server/store/sqlite/attachment.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type AttachmentStore struct{}
|
||||
|
||||
func NewAttachmentStore() *AttachmentStore { return &AttachmentStore{} }
|
||||
|
||||
// ── columns shared across queries ──────────
|
||||
const attachmentCols = `id, channel_id, user_id, message_id, filename, content_type,
|
||||
size_bytes, storage_key, extracted_text, metadata, created_at`
|
||||
|
||||
// scanAttachment scans a row into an Attachment struct.
|
||||
func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) {
|
||||
var a models.Attachment
|
||||
var messageID sql.NullString
|
||||
var extractedText sql.NullString
|
||||
var metadataJSON []byte
|
||||
|
||||
err := row.Scan(
|
||||
&a.ID, &a.ChannelID, &a.UserID, &messageID,
|
||||
&a.Filename, &a.ContentType, &a.SizeBytes,
|
||||
&a.StorageKey, &extractedText, &metadataJSON, st(&a.CreatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.MessageID = NullableStringPtr(messageID)
|
||||
if extractedText.Valid {
|
||||
a.ExtractedText = &extractedText.String
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &a.Metadata)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) Create(ctx context.Context, a *models.Attachment) error {
|
||||
a.ID = store.NewID()
|
||||
a.CreatedAt = time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO attachments (id, channel_id, user_id, message_id, filename, content_type,
|
||||
size_bytes, storage_key, extracted_text, metadata, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
a.ID, a.ChannelID, a.UserID, models.NullString(a.MessageID),
|
||||
a.Filename, a.ContentType, a.SizeBytes,
|
||||
a.StorageKey, models.NullString(a.ExtractedText),
|
||||
ToJSON(a.Metadata), a.CreatedAt.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) GetByID(ctx context.Context, id string) (*models.Attachment, error) {
|
||||
row := DB.QueryRowContext(ctx, `SELECT `+attachmentCols+` FROM attachments WHERE id = ?`, id)
|
||||
return scanAttachment(row)
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE channel_id = ? ORDER BY created_at`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Attachment
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE message_id = ? ORDER BY created_at`, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Attachment
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) SetMessageID(ctx context.Context, attachmentID, messageID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE attachments SET message_id = ? WHERE id = ?`,
|
||||
messageID, attachmentID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error {
|
||||
// Merge into existing metadata using jsonb || operator
|
||||
metaJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = DB.ExecContext(ctx,
|
||||
`UPDATE attachments SET metadata = metadata || ? WHERE id = ?`,
|
||||
metaJSON, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) SetExtractedText(ctx context.Context, id string, text string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE attachments SET extracted_text = ? WHERE id = ?`,
|
||||
text, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete removes an attachment and returns the deleted row (for storage cleanup).
|
||||
func (s *AttachmentStore) Delete(ctx context.Context, id string) (*models.Attachment, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`DELETE FROM attachments WHERE id = ?
|
||||
RETURNING `+attachmentCols, id)
|
||||
return scanAttachment(row)
|
||||
}
|
||||
|
||||
// DeleteByChannel removes all attachments for a channel and returns storage keys.
|
||||
func (s *AttachmentStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`DELETE FROM attachments WHERE channel_id = ? RETURNING storage_key`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
return keys, err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) {
|
||||
var total sql.NullInt64
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(SUM(size_bytes), 0) FROM attachments WHERE user_id = ?`,
|
||||
userID).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total.Int64, nil
|
||||
}
|
||||
|
||||
func (s *AttachmentStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.Attachment, error) {
|
||||
cutoff := time.Now().Add(-olderThan)
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments
|
||||
WHERE message_id IS NULL AND created_at < ?
|
||||
ORDER BY created_at`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Attachment
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
84
server/store/sqlite/audit.go
Normal file
84
server/store/sqlite/audit.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
entry.ID = store.NewID()
|
||||
entry.CreatedAt = time.Now().UTC()
|
||||
metadataJSON := ToJSON(entry.Metadata)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO audit_log (id, actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
entry.ID, models.NullString(entry.ActorID), entry.Action, entry.ResourceType,
|
||||
entry.ResourceID, metadataJSON, entry.IPAddress, entry.UserAgent,
|
||||
entry.CreatedAt.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
countQ, countArgs := b.CountBuild()
|
||||
var total int
|
||||
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
|
||||
|
||||
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 string
|
||||
err := rows.Scan(&e.ID, &actorID, &e.Action, &e.ResourceType, &e.ResourceID,
|
||||
&metadataJSON, &e.IPAddress, &e.UserAgent, st(&e.CreatedAt))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
e.ActorID = NullableStringPtr(actorID)
|
||||
json.Unmarshal([]byte(metadataJSON), &e.Metadata)
|
||||
result = append(result, e)
|
||||
}
|
||||
return result, total, rows.Err()
|
||||
}
|
||||
241
server/store/sqlite/catalog.go
Normal file
241
server/store/sqlite/catalog.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package sqlite
|
||||
|
||||
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, model_type,
|
||||
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.model_type,
|
||||
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)
|
||||
pricingJSON := ToJSON(e.Pricing) // nil → "{}", always valid JSONB
|
||||
|
||||
// Normalize model type: empty → "chat" (the default)
|
||||
modelType := e.ModelType
|
||||
if modelType == "" {
|
||||
modelType = "chat"
|
||||
}
|
||||
|
||||
var existingID string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
"SELECT id FROM model_catalog WHERE provider_config_id = ? AND model_id = ?",
|
||||
providerConfigID, e.ModelID,
|
||||
).Scan(&existingID)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// Insert new (disabled by default)
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
INSERT INTO model_catalog (id, provider_config_id, model_id, display_name, model_type,
|
||||
capabilities, pricing, visibility, last_synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'disabled', ?)`,
|
||||
store.NewID(), providerConfigID, e.ModelID, e.DisplayName, modelType, 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 = ?, model_type = ?, capabilities = ?,
|
||||
pricing = ?, last_synced_at = ?
|
||||
WHERE id = ?`,
|
||||
e.DisplayName, modelType, 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 = ?", 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 = ? AND model_id = ?", 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 = ? 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 = 1
|
||||
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 = ? 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 = ? 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)
|
||||
}
|
||||
|
||||
// ListAllGlobal returns all catalog entries whose provider_config has scope='global'.
|
||||
// Used by admin panel — admin should not see user BYOK or team-level models.
|
||||
func (s *CatalogStore) ListAllGlobal(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 pc.scope = 'global'
|
||||
ORDER BY mc.model_id`, catalogColsMC))
|
||||
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 = ? WHERE id = ?", 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 = ? WHERE provider_config_id = ?",
|
||||
visibility, providerConfigID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *CatalogStore) BulkSetVisibilityAll(ctx context.Context, visibility string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE model_catalog SET visibility = ?
|
||||
WHERE provider_config_id IN (
|
||||
SELECT id FROM provider_configs WHERE scope = 'global'
|
||||
)`, visibility)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *CatalogStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, "DELETE FROM model_catalog WHERE id = ?", 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 = ?", 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 *time.Time
|
||||
err := row.Scan(
|
||||
&e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, &e.ModelType,
|
||||
&capsJSON, &pricingJSON, &e.Visibility, stN(&lastSynced),
|
||||
st(&e.CreatedAt), st(&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)
|
||||
}
|
||||
e.LastSyncedAt = lastSynced
|
||||
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 *time.Time
|
||||
err := rows.Scan(
|
||||
&e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, &e.ModelType,
|
||||
&capsJSON, &pricingJSON, &e.Visibility, stN(&lastSynced),
|
||||
st(&e.CreatedAt), st(&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)
|
||||
}
|
||||
e.LastSyncedAt = lastSynced
|
||||
result = append(result, e)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
227
server/store/sqlite/channel.go
Normal file
227
server/store/sqlite/channel.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
ch.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
ch.CreatedAt = now
|
||||
ch.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO channels (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)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
ch.ID, 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),
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
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 = ?`, id).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, &ch.SystemPrompt,
|
||||
&providerConfigID, &ch.IsArchived, &ch.IsPinned, &folderID, &teamID, &settingsJSON,
|
||||
st(&ch.CreatedAt), st(&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 = ?", 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 = ?", 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, st(&ch.CreatedAt), st(&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 LIKE ?", "%"+query+"%")
|
||||
b.OrderBy("updated_at", "DESC")
|
||||
b.Paginate(opts)
|
||||
|
||||
var total int
|
||||
DB.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM channels WHERE user_id = ? AND title LIKE ?",
|
||||
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, st(&ch.CreatedAt), st(&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 = ? AND user_id = ?`,
|
||||
channelID, userID).Scan(&c.ID, &c.ChannelID, &c.UserID, &leafID, st(&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 (id, channel_id, user_id, active_leaf_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = excluded.active_leaf_id, updated_at = datetime('now')`,
|
||||
store.NewID(), channelID, userID, leafID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (channel_id, model_id) DO UPDATE SET
|
||||
provider_config_id = excluded.provider_config_id, display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`,
|
||||
store.NewID(), 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, ''),
|
||||
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
|
||||
FROM channel_models WHERE channel_id = ?`, 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 = ? AND user_id = ?)",
|
||||
channelID, userID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
201
server/store/sqlite/extension.go
Normal file
201
server/store/sqlite/extension.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type ExtensionStore struct{}
|
||||
|
||||
func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} }
|
||||
|
||||
func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error {
|
||||
ext.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
ext.CreatedAt = now
|
||||
ext.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO extensions (id, ext_id, name, version, tier, description, author,
|
||||
manifest, is_system, is_enabled, scope, team_id, installed_by, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
ext.ID, ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author,
|
||||
ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope,
|
||||
models.NullString(ext.TeamID), models.NullString(ext.InstalledBy),
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) {
|
||||
return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = ?`, id)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) {
|
||||
return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = ?`, extID)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE extensions SET
|
||||
name = ?, version = ?, description = ?, author = ?,
|
||||
manifest = ?, is_system = ?, is_enabled = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
ext.Name, ext.Version, ext.Description, ext.Author,
|
||||
ext.Manifest, ext.IsSystem, ext.IsEnabled, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) {
|
||||
return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) {
|
||||
return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = 1 ORDER BY name`)
|
||||
}
|
||||
|
||||
// ListForUser returns all enabled extensions with user-specific overrides merged in.
|
||||
// System extensions are always included (users can't disable them).
|
||||
// Non-system extensions respect per-user enabled toggle.
|
||||
func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author,
|
||||
e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by,
|
||||
e.created_at, e.updated_at,
|
||||
eus.is_enabled AS user_enabled,
|
||||
eus.settings AS user_settings
|
||||
FROM extensions e
|
||||
LEFT JOIN extension_user_settings eus
|
||||
ON eus.extension_id = e.id AND eus.user_id = ?
|
||||
WHERE e.is_enabled = 1
|
||||
AND (e.is_system = 1 OR COALESCE(eus.is_enabled, 1) = 1)
|
||||
ORDER BY e.name`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.UserExtension
|
||||
for rows.Next() {
|
||||
var ue models.UserExtension
|
||||
var teamID, installedBy sql.NullString
|
||||
var userEnabled sql.NullBool
|
||||
var userSettings []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier,
|
||||
&ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem,
|
||||
&ue.IsEnabled, &ue.Scope, &teamID, &installedBy,
|
||||
st(&ue.CreatedAt), st(&ue.UpdatedAt),
|
||||
&userEnabled, &userSettings,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ue.TeamID = NullableStringPtr(teamID)
|
||||
ue.InstalledBy = NullableStringPtr(installedBy)
|
||||
if userEnabled.Valid {
|
||||
ue.UserEnabled = &userEnabled.Bool
|
||||
}
|
||||
if userSettings != nil {
|
||||
raw := json.RawMessage(userSettings)
|
||||
ue.UserSettings = &raw
|
||||
}
|
||||
result = append(result, ue)
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]models.UserExtension, 0)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) {
|
||||
var eus models.ExtensionUserSettings
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT extension_id, user_id, settings, is_enabled
|
||||
FROM extension_user_settings
|
||||
WHERE extension_id = ? AND user_id = ?`,
|
||||
extID, userID,
|
||||
).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &eus, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (extension_id, user_id)
|
||||
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
|
||||
eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
DELETE FROM extension_user_settings WHERE extension_id = ? AND user_id = ?`,
|
||||
extID, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────
|
||||
|
||||
func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) {
|
||||
var ext models.Extension
|
||||
var teamID, installedBy sql.NullString
|
||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
|
||||
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
|
||||
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
|
||||
st(&ext.CreatedAt), st(&ext.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ext.TeamID = NullableStringPtr(teamID)
|
||||
ext.InstalledBy = NullableStringPtr(installedBy)
|
||||
return &ext, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) {
|
||||
rows, err := DB.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.Extension
|
||||
for rows.Next() {
|
||||
var ext models.Extension
|
||||
var teamID, installedBy sql.NullString
|
||||
if err := rows.Scan(
|
||||
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
|
||||
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
|
||||
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
|
||||
st(&ext.CreatedAt), st(&ext.UpdatedAt),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ext.TeamID = NullableStringPtr(teamID)
|
||||
ext.InstalledBy = NullableStringPtr(installedBy)
|
||||
result = append(result, ext)
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]models.Extension, 0)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
58
server/store/sqlite/global_config.go
Normal file
58
server/store/sqlite/global_config.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package sqlite
|
||||
|
||||
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 string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
"SELECT value FROM global_settings WHERE key = ?", key).Scan(&valueJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result models.JSONMap
|
||||
json.Unmarshal([]byte(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 (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by, updated_at = datetime('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, valueJSON string
|
||||
if err := rows.Scan(&key, &valueJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
var m models.JSONMap
|
||||
json.Unmarshal([]byte(valueJSON), &m)
|
||||
result[key] = m
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
213
server/store/sqlite/groups.go
Normal file
213
server/store/sqlite/groups.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── GroupStore ──────────────────────────────
|
||||
|
||||
type GroupStore struct{}
|
||||
|
||||
func NewGroupStore() *GroupStore { return &GroupStore{} }
|
||||
|
||||
func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
|
||||
g.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
g.CreatedAt = now
|
||||
g.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO groups (id, name, description, scope, team_id, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
g.ID, g.Name, g.Description, g.Scope,
|
||||
models.NullString(g.TeamID), g.CreatedBy,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) {
|
||||
var g models.Group
|
||||
var teamID sql.NullString
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count
|
||||
FROM groups g WHERE g.id = ?`, id).Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy,
|
||||
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func (s *GroupStore) Update(ctx context.Context, id string, name, description *string) error {
|
||||
b := NewUpdate("groups")
|
||||
if name != nil {
|
||||
b.Set("name", *name)
|
||||
}
|
||||
if description != nil {
|
||||
b.Set("description", *description)
|
||||
}
|
||||
if !b.HasSets() {
|
||||
return nil
|
||||
}
|
||||
b.Where("id", id)
|
||||
res, err := b.Exec(DB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GroupStore) Delete(ctx context.Context, id string) error {
|
||||
res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Scoped Listing ──────────────────────────
|
||||
|
||||
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
FROM groups g
|
||||
ORDER BY g.scope, g.name`)
|
||||
}
|
||||
|
||||
func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
FROM groups g
|
||||
WHERE g.scope = 'team' AND g.team_id = ?
|
||||
ORDER BY g.name`, teamID)
|
||||
}
|
||||
|
||||
func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
FROM groups g
|
||||
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = ?)
|
||||
ORDER BY g.scope, g.name`, userID)
|
||||
}
|
||||
|
||||
// ── Members ─────────────────────────────────
|
||||
|
||||
func (s *GroupStore) AddMember(ctx context.Context, groupID, userID, addedBy string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO group_members (id, group_id, user_id, added_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (group_id, user_id) DO NOTHING`,
|
||||
store.NewID(), groupID, userID, addedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *GroupStore) RemoveMember(ctx context.Context, groupID, userID string) error {
|
||||
res, err := DB.ExecContext(ctx,
|
||||
"DELETE FROM group_members WHERE group_id = ? AND user_id = ?",
|
||||
groupID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GroupStore) ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT gm.id, gm.group_id, gm.user_id, gm.added_by, gm.added_at,
|
||||
u.username, u.email, COALESCE(u.display_name, '')
|
||||
FROM group_members gm
|
||||
JOIN users u ON u.id = gm.user_id
|
||||
WHERE gm.group_id = ?
|
||||
ORDER BY u.username`, groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.GroupMember
|
||||
for rows.Next() {
|
||||
var m models.GroupMember
|
||||
if err := rows.Scan(&m.ID, &m.GroupID, &m.UserID, &m.AddedBy, st(&m.AddedAt),
|
||||
&m.Username, &m.Email, &m.DisplayName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, m)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *GroupStore) IsMember(ctx context.Context, groupID, userID string) (bool, error) {
|
||||
var exists bool
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ?)`,
|
||||
groupID, userID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *GroupStore) GetUserGroupIDs(ctx context.Context, userID string) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
"SELECT group_id FROM group_members WHERE user_id = ?", 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()
|
||||
}
|
||||
|
||||
// ── Scanners ────────────────────────────────
|
||||
|
||||
func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.Group, error) {
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("queryGroups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.Group
|
||||
for rows.Next() {
|
||||
var g models.Group
|
||||
var teamID sql.NullString
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID,
|
||||
&g.CreatedBy, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
result = append(result, g)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
328
server/store/sqlite/helpers.go
Normal file
328
server/store/sqlite/helpers.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// DB is the shared database connection pool.
|
||||
var DB *sql.DB
|
||||
|
||||
// timeFmt is the SQLite-compatible time format for explicit timestamps.
|
||||
// Must match datetime('now') output used in schema DEFAULT expressions.
|
||||
const timeFmt = "2006-01-02 15:04:05"
|
||||
|
||||
// ── Time Scanner ────────────────────────────
|
||||
// modernc/sqlite returns TEXT columns as raw strings, not time.Time.
|
||||
// st() wraps a *time.Time destination so Scan auto-parses the string.
|
||||
|
||||
type sqliteTime struct{ t *time.Time }
|
||||
|
||||
func st(t *time.Time) *sqliteTime { return &sqliteTime{t: t} }
|
||||
|
||||
func (s *sqliteTime) Scan(src interface{}) error {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
switch v := src.(type) {
|
||||
case time.Time:
|
||||
*s.t = v
|
||||
return nil
|
||||
case string:
|
||||
for _, f := range timeFormats {
|
||||
if p, err := time.Parse(f, v); err == nil {
|
||||
*s.t = p
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("sqliteTime: cannot parse %q", v)
|
||||
default:
|
||||
return fmt.Errorf("sqliteTime: unsupported type %T", src)
|
||||
}
|
||||
}
|
||||
|
||||
var timeFormats = []string{
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05Z",
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05.000000000+00:00",
|
||||
}
|
||||
|
||||
// stN wraps a *time.Time pointer for nullable columns.
|
||||
type sqliteTimeNullable struct{ t **time.Time }
|
||||
|
||||
func stN(t **time.Time) *sqliteTimeNullable { return &sqliteTimeNullable{t: t} }
|
||||
|
||||
func (s *sqliteTimeNullable) Scan(src interface{}) error {
|
||||
if src == nil {
|
||||
*s.t = nil
|
||||
return nil
|
||||
}
|
||||
var parsed time.Time
|
||||
scanner := st(&parsed)
|
||||
if err := scanner.Scan(src); err != nil {
|
||||
return err
|
||||
}
|
||||
*s.t = &parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDB configures the shared database connection for all stores.
|
||||
func SetDB(db *sql.DB) {
|
||||
DB = db
|
||||
}
|
||||
|
||||
// ── Dynamic SQL Builder (? placeholders) ────
|
||||
|
||||
// UpdateBuilder constructs a dynamic UPDATE with ? placeholders.
|
||||
type UpdateBuilder struct {
|
||||
table string
|
||||
sets []string
|
||||
args []interface{}
|
||||
where []string
|
||||
}
|
||||
|
||||
func NewUpdate(table string) *UpdateBuilder {
|
||||
return &UpdateBuilder{table: table}
|
||||
}
|
||||
|
||||
func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder {
|
||||
b.sets = append(b.sets, col+" = ?")
|
||||
b.args = append(b.args, val)
|
||||
return b
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func (b *UpdateBuilder) SetIf(col string, val interface{}, set bool) *UpdateBuilder {
|
||||
if set {
|
||||
return b.Set(col, val)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// SetExpr sets a column to a raw SQL expression (not parameterized).
|
||||
func (b *UpdateBuilder) SetExpr(col, expr string) *UpdateBuilder {
|
||||
b.sets = append(b.sets, col+" = "+expr)
|
||||
return b
|
||||
}
|
||||
|
||||
// nowExpr returns the SQL expression for "current timestamp" in ISO 8601 format.
|
||||
const nowExpr = "datetime('now')"
|
||||
|
||||
func (b *UpdateBuilder) Where(col string, val interface{}) *UpdateBuilder {
|
||||
b.where = append(b.where, col+" = ?")
|
||||
b.args = append(b.args, val)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *UpdateBuilder) HasSets() bool {
|
||||
return len(b.sets) > 0
|
||||
}
|
||||
|
||||
func (b *UpdateBuilder) Build() (string, []interface{}) {
|
||||
q := fmt.Sprintf("UPDATE %s SET %s", b.table, strings.Join(b.sets, ", "))
|
||||
if len(b.where) > 0 {
|
||||
q += " WHERE " + strings.Join(b.where, " AND ")
|
||||
}
|
||||
return q, b.args
|
||||
}
|
||||
|
||||
func (b *UpdateBuilder) Exec(db *sql.DB) (sql.Result, error) {
|
||||
q, args := b.Build()
|
||||
return db.Exec(q, args...)
|
||||
}
|
||||
|
||||
// ── Select Builder ──────────────────────────
|
||||
|
||||
type SelectBuilder struct {
|
||||
cols string
|
||||
table string
|
||||
joins []string
|
||||
where []string
|
||||
args []interface{}
|
||||
orderBy string
|
||||
limit int
|
||||
offset int
|
||||
}
|
||||
|
||||
func NewSelect(cols, table string) *SelectBuilder {
|
||||
return &SelectBuilder{cols: cols, table: table}
|
||||
}
|
||||
|
||||
func (b *SelectBuilder) Join(join string) *SelectBuilder {
|
||||
b.joins = append(b.joins, join)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *SelectBuilder) Where(clause string, args ...interface{}) *SelectBuilder {
|
||||
b.where = append(b.where, clause)
|
||||
b.args = append(b.args, args...)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *SelectBuilder) WhereRaw(clause string) *SelectBuilder {
|
||||
b.where = append(b.where, clause)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *SelectBuilder) OrderBy(col, order string) *SelectBuilder {
|
||||
if order == "" {
|
||||
order = "DESC"
|
||||
}
|
||||
b.orderBy = fmt.Sprintf("%s %s", col, strings.ToUpper(order))
|
||||
return b
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ── JSON Helpers ────────────────────────────
|
||||
|
||||
// ToJSON marshals a value to JSON string for TEXT columns.
|
||||
func ToJSON(v interface{}) string {
|
||||
if v == nil {
|
||||
return "{}"
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ScanJSON scans a TEXT column (JSON) 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 JSON type: %T", src)
|
||||
}
|
||||
return json.Unmarshal(data, dst)
|
||||
}
|
||||
|
||||
// ArrayToJSON converts a []string to JSON text for storage.
|
||||
func ArrayToJSON(arr []string) string {
|
||||
if arr == nil {
|
||||
return "[]"
|
||||
}
|
||||
b, _ := json.Marshal(arr)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ScanArray scans a JSON text array column into []string.
|
||||
func ScanArray(src interface{}) []string {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
var data string
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
data = v
|
||||
case []byte:
|
||||
data = string(v)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(data), &arr); err != nil {
|
||||
return nil
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// ── Nullable Helpers ────────────────────────
|
||||
|
||||
func NullableString(ns sql.NullString) string {
|
||||
if ns.Valid {
|
||||
return ns.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func NullableStringPtr(ns sql.NullString) *string {
|
||||
if ns.Valid {
|
||||
return &ns.String
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NullableFloat64Ptr(nf sql.NullFloat64) *float64 {
|
||||
if nf.Valid {
|
||||
return &nf.Float64
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NullableIntPtr(ni sql.NullInt64) *int {
|
||||
if ni.Valid {
|
||||
v := int(ni.Int64)
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── UUID Generation ─────────────────────────
|
||||
|
||||
// NewID generates a new UUID string. Imported from the uuid package
|
||||
// at the store level to keep the helper package dependency-light.
|
||||
// Callers should use store.NewID() instead.
|
||||
641
server/store/sqlite/knowledge_bases.go
Normal file
641
server/store/sqlite/knowledge_bases.go
Normal file
@@ -0,0 +1,641 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── KnowledgeBaseStore ──────────────────────────
|
||||
|
||||
type KnowledgeBaseStore struct{}
|
||||
|
||||
func NewKnowledgeBaseStore() *KnowledgeBaseStore { return &KnowledgeBaseStore{} }
|
||||
|
||||
// ── KB CRUD ──────────────────────────────────────
|
||||
|
||||
func (s *KnowledgeBaseStore) Create(ctx context.Context, kb *models.KnowledgeBase) error {
|
||||
kb.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
kb.CreatedAt = now
|
||||
kb.UpdatedAt = now
|
||||
// document_count, chunk_count, total_bytes default to 0 (Go zero values)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO knowledge_bases (id, name, description, scope, owner_id, team_id, embedding_config, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
kb.ID, kb.Name, kb.Description, kb.Scope,
|
||||
models.NullString(kb.OwnerID), models.NullString(kb.TeamID),
|
||||
ToJSON(kb.EmbeddingConfig), kb.Status,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) GetByID(ctx context.Context, id string) (*models.KnowledgeBase, error) {
|
||||
kb, err := scanKB(DB.QueryRowContext(ctx, `
|
||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
||||
FROM knowledge_bases WHERE id = ?`, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kb, nil
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
|
||||
b := NewUpdate("knowledge_bases")
|
||||
for k, v := range fields {
|
||||
if k == "embedding_config" {
|
||||
b.SetJSON(k, v)
|
||||
} else {
|
||||
b.Set(k, v)
|
||||
}
|
||||
}
|
||||
b.SetExpr("updated_at", nowExpr)
|
||||
if !b.HasSets() {
|
||||
return nil
|
||||
}
|
||||
b.Where("id", id)
|
||||
_, err := b.Exec(DB)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, "DELETE FROM knowledge_bases WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Scoped Listing ──────────────────────────────
|
||||
|
||||
func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
|
||||
q := `
|
||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
||||
FROM knowledge_bases
|
||||
WHERE scope = 'global'
|
||||
OR (scope = 'personal' AND owner_id = ?)`
|
||||
|
||||
args := []interface{}{userID}
|
||||
|
||||
if len(teamIDs) > 0 {
|
||||
placeholders := makeQPlaceholders(len(teamIDs))
|
||||
q += fmt.Sprintf(` OR (scope = 'team' AND team_id IN (%s))`, placeholders)
|
||||
for _, tid := range teamIDs {
|
||||
args = append(args, tid)
|
||||
}
|
||||
}
|
||||
|
||||
// Group-granted KBs
|
||||
q += `
|
||||
OR id IN (
|
||||
SELECT rg.resource_id FROM resource_grants rg
|
||||
WHERE rg.resource_type = 'knowledge_base'
|
||||
AND (
|
||||
rg.grant_scope = 'global'
|
||||
OR (rg.grant_scope = 'groups'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM group_members gm
|
||||
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
|
||||
WHERE gm.user_id = ?
|
||||
))
|
||||
)
|
||||
)`
|
||||
args = append(args, userID)
|
||||
|
||||
q += ` ORDER BY name`
|
||||
return queryKBs(ctx, q, args...)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) ListGlobal(ctx context.Context) ([]models.KnowledgeBase, error) {
|
||||
return queryKBs(ctx, `
|
||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
||||
FROM knowledge_bases WHERE scope = 'global' ORDER BY name`)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) ListForTeam(ctx context.Context, teamID string) ([]models.KnowledgeBase, error) {
|
||||
return queryKBs(ctx, `
|
||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
||||
FROM knowledge_bases WHERE team_id = ? ORDER BY name`, teamID)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) ListPersonal(ctx context.Context, userID string) ([]models.KnowledgeBase, error) {
|
||||
return queryKBs(ctx, `
|
||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
||||
FROM knowledge_bases WHERE scope = 'personal' AND owner_id = ? ORDER BY name`, userID)
|
||||
}
|
||||
|
||||
// ── Documents ────────────────────────────────────
|
||||
|
||||
func (s *KnowledgeBaseStore) CreateDocument(ctx context.Context, doc *models.KBDocument) error {
|
||||
doc.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
doc.CreatedAt = now
|
||||
doc.UpdatedAt = now
|
||||
// chunk_count defaults to 0 (Go zero value)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO kb_documents (id, kb_id, filename, content_type, size_bytes, storage_key, status, uploaded_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
doc.ID, doc.KBID, doc.Filename, doc.ContentType, doc.SizeBytes,
|
||||
doc.StorageKey, doc.Status, doc.UploadedBy,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) GetDocument(ctx context.Context, id string) (*models.KBDocument, error) {
|
||||
var doc models.KBDocument
|
||||
var extractedText, errMsg sql.NullString
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, kb_id, filename, content_type, size_bytes, storage_key,
|
||||
extracted_text, chunk_count, status, error, uploaded_by, created_at, updated_at
|
||||
FROM kb_documents WHERE id = ?`, id).Scan(
|
||||
&doc.ID, &doc.KBID, &doc.Filename, &doc.ContentType, &doc.SizeBytes,
|
||||
&doc.StorageKey, &extractedText, &doc.ChunkCount, &doc.Status,
|
||||
&errMsg, &doc.UploadedBy, st(&doc.CreatedAt), st(&doc.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.ExtractedText = NullableStringPtr(extractedText)
|
||||
doc.Error = NullableStringPtr(errMsg)
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) ListDocuments(ctx context.Context, kbID string) ([]models.KBDocument, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, kb_id, filename, content_type, size_bytes, storage_key,
|
||||
chunk_count, status, error, uploaded_by, created_at, updated_at
|
||||
FROM kb_documents WHERE kb_id = ? ORDER BY created_at`, kbID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.KBDocument
|
||||
for rows.Next() {
|
||||
var doc models.KBDocument
|
||||
var errMsg sql.NullString
|
||||
err := rows.Scan(&doc.ID, &doc.KBID, &doc.Filename, &doc.ContentType,
|
||||
&doc.SizeBytes, &doc.StorageKey, &doc.ChunkCount, &doc.Status,
|
||||
&errMsg, &doc.UploadedBy, st(&doc.CreatedAt), st(&doc.UpdatedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.Error = NullableStringPtr(errMsg)
|
||||
result = append(result, doc)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) UpdateDocumentStatus(ctx context.Context, id string, status string, errMsg *string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE kb_documents SET status = ?, error = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`, status, models.NullString(errMsg), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) UpdateDocumentText(ctx context.Context, id string, text string, chunkCount int) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE kb_documents SET extracted_text = ?, chunk_count = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`, text, chunkCount, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) UpdateDocumentStorageKey(ctx context.Context, id string, storageKey string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE kb_documents SET storage_key = ? WHERE id = ?`, storageKey, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) {
|
||||
var doc models.KBDocument
|
||||
var errMsg sql.NullString
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
DELETE FROM kb_documents WHERE id = ?
|
||||
RETURNING id, kb_id, filename, storage_key, status, error`,
|
||||
id).Scan(&doc.ID, &doc.KBID, &doc.Filename, &doc.StorageKey, &doc.Status, &errMsg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.Error = NullableStringPtr(errMsg)
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
// ── Chunks ───────────────────────────────────────
|
||||
|
||||
func (s *KnowledgeBaseStore) InsertChunks(ctx context.Context, chunks []models.KBChunk) error {
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Batch insert using multi-row INSERT.
|
||||
// Embeddings stored as JSON text (e.g. "[0.1,0.2,...]") for app-level cosine similarity.
|
||||
valueParts := make([]string, 0, len(chunks))
|
||||
args := make([]interface{}, 0, len(chunks)*8)
|
||||
|
||||
for _, c := range chunks {
|
||||
c.ID = store.NewID()
|
||||
valueParts = append(valueParts, "(?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
var embJSON interface{}
|
||||
if len(c.Embedding) > 0 {
|
||||
embJSON = ToJSON(c.Embedding)
|
||||
}
|
||||
args = append(args, c.ID, c.KBID, c.DocumentID, c.ChunkIndex,
|
||||
c.Content, c.TokenCount, embJSON, ToJSON(c.Metadata))
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`INSERT INTO kb_chunks (id, kb_id, document_id, chunk_index, content, token_count, embedding, metadata)
|
||||
VALUES %s`, strings.Join(valueParts, ", "))
|
||||
|
||||
_, err := DB.ExecContext(ctx, q, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) DeleteChunksForDocument(ctx context.Context, documentID string) error {
|
||||
_, err := DB.ExecContext(ctx, "DELETE FROM kb_chunks WHERE document_id = ?", documentID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SimilaritySearch performs cosine similarity search in Go.
|
||||
// Loads candidate chunks from the specified KBs, decodes their JSON-stored
|
||||
// embeddings, computes cosine similarity against queryVec, and returns the
|
||||
// top results above threshold. For SQLite-scale deployments (single-user,
|
||||
// edge, dev) this is perfectly adequate without requiring CGO/sqlite-vec.
|
||||
func (s *KnowledgeBaseStore) SimilaritySearch(ctx context.Context, kbIDs []string, queryVec []float64, threshold float64, limit int) ([]models.KBSearchResult, error) {
|
||||
if len(kbIDs) == 0 || len(queryVec) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
|
||||
// Load candidate chunks with embeddings
|
||||
placeholders := makeQPlaceholders(len(kbIDs))
|
||||
q := fmt.Sprintf(`
|
||||
SELECT c.content, c.embedding, c.metadata, d.filename, kb.name
|
||||
FROM kb_chunks c
|
||||
JOIN kb_documents d ON c.document_id = d.id
|
||||
JOIN knowledge_bases kb ON c.kb_id = kb.id
|
||||
WHERE c.kb_id IN (%s)
|
||||
AND c.embedding IS NOT NULL`, placeholders)
|
||||
|
||||
args := make([]interface{}, len(kbIDs))
|
||||
for i, id := range kbIDs {
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type candidate struct {
|
||||
result models.KBSearchResult
|
||||
similarity float64
|
||||
}
|
||||
var candidates []candidate
|
||||
|
||||
for rows.Next() {
|
||||
var content, embJSON, filename, kbName string
|
||||
var metaJSON sql.NullString
|
||||
if err := rows.Scan(&content, &embJSON, &metaJSON, &filename, &kbName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decode embedding from JSON
|
||||
var embedding []float64
|
||||
if err := json.Unmarshal([]byte(embJSON), &embedding); err != nil {
|
||||
continue // skip chunks with malformed embeddings
|
||||
}
|
||||
|
||||
sim := cosineSimilarity(queryVec, embedding)
|
||||
if sim > threshold {
|
||||
r := models.KBSearchResult{
|
||||
Content: content,
|
||||
Filename: filename,
|
||||
KBName: kbName,
|
||||
Similarity: sim,
|
||||
}
|
||||
if metaJSON.Valid {
|
||||
ScanJSON(metaJSON.String, &r.Metadata)
|
||||
}
|
||||
candidates = append(candidates, candidate{result: r, similarity: sim})
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sort by similarity descending
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].similarity > candidates[j].similarity
|
||||
})
|
||||
|
||||
// Cap at limit
|
||||
if len(candidates) > limit {
|
||||
candidates = candidates[:limit]
|
||||
}
|
||||
|
||||
results := make([]models.KBSearchResult, len(candidates))
|
||||
for i, c := range candidates {
|
||||
results[i] = c.result
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// cosineSimilarity computes cosine similarity between two vectors.
|
||||
// Returns 0 if either vector is zero-length or all-zeros.
|
||||
func cosineSimilarity(a, b []float64) float64 {
|
||||
if len(a) != len(b) || len(a) == 0 {
|
||||
return 0
|
||||
}
|
||||
var dot, normA, normB float64
|
||||
for i := range a {
|
||||
dot += a[i] * b[i]
|
||||
normA += a[i] * a[i]
|
||||
normB += b[i] * b[i]
|
||||
}
|
||||
if normA == 0 || normB == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||
}
|
||||
|
||||
// ── Channel Links ─────────────────────────────────
|
||||
|
||||
func (s *KnowledgeBaseStore) SetChannelKBs(ctx context.Context, channelID string, kbIDs []string) error {
|
||||
tx, err := DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM channel_knowledge_bases WHERE channel_id = ?", channelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, kbID := range kbIDs {
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO channel_knowledge_bases (channel_id, kb_id, enabled) VALUES (?, ?, 1)`,
|
||||
channelID, kbID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) GetChannelKBs(ctx context.Context, channelID string) ([]models.ChannelKB, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT ckb.kb_id, kb.name, ckb.enabled, kb.document_count
|
||||
FROM channel_knowledge_bases ckb
|
||||
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
|
||||
WHERE ckb.channel_id = ?
|
||||
ORDER BY kb.name`, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.ChannelKB
|
||||
for rows.Next() {
|
||||
var ckb models.ChannelKB
|
||||
if err := rows.Scan(&ckb.KBID, &ckb.KBName, &ckb.Enabled, &ckb.DocumentCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, ckb)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseStore) GetActiveKBIDs(ctx context.Context, channelID string, userID string, teamIDs []string) ([]string, error) {
|
||||
q := `
|
||||
SELECT ckb.kb_id
|
||||
FROM channel_knowledge_bases ckb
|
||||
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
|
||||
WHERE ckb.channel_id = ? AND ckb.enabled = 1
|
||||
AND (
|
||||
kb.scope = 'global'
|
||||
OR (kb.scope = 'personal' AND kb.owner_id = ?)`
|
||||
|
||||
args := []interface{}{channelID, userID}
|
||||
|
||||
if len(teamIDs) > 0 {
|
||||
placeholders := makeQPlaceholders(len(teamIDs))
|
||||
q += fmt.Sprintf(` OR (kb.scope = 'team' AND kb.team_id IN (%s))`, placeholders)
|
||||
for _, tid := range teamIDs {
|
||||
args = append(args, tid)
|
||||
}
|
||||
}
|
||||
|
||||
q += `)`
|
||||
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
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()
|
||||
}
|
||||
|
||||
// ── Stats ────────────────────────────────────────
|
||||
|
||||
func (s *KnowledgeBaseStore) UpdateStats(ctx context.Context, kbID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE knowledge_bases SET
|
||||
document_count = (SELECT COUNT(*) FROM kb_documents WHERE kb_id = ? AND status != 'error'),
|
||||
chunk_count = (SELECT COUNT(*) FROM kb_chunks WHERE kb_id = ?),
|
||||
total_bytes = COALESCE((SELECT SUM(size_bytes) FROM kb_documents WHERE kb_id = ?), 0),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`, kbID, kbID, kbID, kbID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Discoverable Management (v0.17.0) ────────────
|
||||
|
||||
func (s *KnowledgeBaseStore) SetDiscoverable(ctx context.Context, kbID string, discoverable bool) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE knowledge_bases SET discoverable = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`, discoverable, kbID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetActiveKBIDsWithPersona returns KB IDs accessible for a channel, including
|
||||
// KBs bound to the active persona.
|
||||
func (s *KnowledgeBaseStore) GetActiveKBIDsWithPersona(ctx context.Context, channelID string, userID string, teamIDs []string, personaID string) ([]string, error) {
|
||||
q := `
|
||||
SELECT ckb.kb_id
|
||||
FROM channel_knowledge_bases ckb
|
||||
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
|
||||
WHERE ckb.channel_id = ? AND ckb.enabled = 1
|
||||
AND (
|
||||
kb.scope = 'global'
|
||||
OR (kb.scope = 'personal' AND kb.owner_id = ?)`
|
||||
|
||||
args := []interface{}{channelID, userID}
|
||||
|
||||
if len(teamIDs) > 0 {
|
||||
placeholders := makeQPlaceholders(len(teamIDs))
|
||||
q += fmt.Sprintf(` OR (kb.scope = 'team' AND kb.team_id IN (%s))`, placeholders)
|
||||
for _, tid := range teamIDs {
|
||||
args = append(args, tid)
|
||||
}
|
||||
}
|
||||
|
||||
q += `)`
|
||||
|
||||
// UNION with persona-bound KBs (bypass discoverable check)
|
||||
if personaID != "" {
|
||||
q += `
|
||||
UNION
|
||||
SELECT pkb.kb_id
|
||||
FROM persona_knowledge_bases pkb
|
||||
JOIN knowledge_bases kb ON pkb.kb_id = kb.id
|
||||
WHERE pkb.persona_id = ?
|
||||
AND kb.chunk_count > 0`
|
||||
args = append(args, personaID)
|
||||
}
|
||||
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// ListDiscoverable returns KBs the user can see AND that are discoverable.
|
||||
func (s *KnowledgeBaseStore) ListDiscoverable(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
|
||||
q := `
|
||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
||||
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
||||
FROM knowledge_bases
|
||||
WHERE discoverable = 1
|
||||
AND (
|
||||
scope = 'global'
|
||||
OR (scope = 'personal' AND owner_id = ?)`
|
||||
|
||||
args := []interface{}{userID}
|
||||
|
||||
if len(teamIDs) > 0 {
|
||||
placeholders := makeQPlaceholders(len(teamIDs))
|
||||
q += fmt.Sprintf(` OR (scope = 'team' AND team_id IN (%s))`, placeholders)
|
||||
for _, tid := range teamIDs {
|
||||
args = append(args, tid)
|
||||
}
|
||||
}
|
||||
|
||||
// Group-granted KBs
|
||||
q += `
|
||||
OR id IN (
|
||||
SELECT rg.resource_id FROM resource_grants rg
|
||||
WHERE rg.resource_type = 'knowledge_base'
|
||||
AND (
|
||||
rg.grant_scope = 'global'
|
||||
OR (rg.grant_scope = 'groups'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM group_members gm
|
||||
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
|
||||
WHERE gm.user_id = ?
|
||||
))
|
||||
)
|
||||
)`
|
||||
args = append(args, userID)
|
||||
|
||||
q += `) ORDER BY name`
|
||||
return queryKBs(ctx, q, args...)
|
||||
}
|
||||
|
||||
// ── Scan Helpers ─────────────────────────────────
|
||||
|
||||
func scanKB(row *sql.Row) (*models.KnowledgeBase, error) {
|
||||
var kb models.KnowledgeBase
|
||||
var ownerID, teamID sql.NullString
|
||||
var embCfgJSON string
|
||||
|
||||
err := row.Scan(
|
||||
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
|
||||
&ownerID, &teamID, &embCfgJSON,
|
||||
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
|
||||
&kb.Status, &kb.Discoverable, st(&kb.CreatedAt), st(&kb.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kb.OwnerID = NullableStringPtr(ownerID)
|
||||
kb.TeamID = NullableStringPtr(teamID)
|
||||
ScanJSON(embCfgJSON, &kb.EmbeddingConfig)
|
||||
return &kb, nil
|
||||
}
|
||||
|
||||
func queryKBs(ctx context.Context, query string, args ...interface{}) ([]models.KnowledgeBase, error) {
|
||||
rows, err := DB.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.KnowledgeBase
|
||||
for rows.Next() {
|
||||
var kb models.KnowledgeBase
|
||||
var ownerID, teamID sql.NullString
|
||||
var embCfgJSON string
|
||||
err := rows.Scan(
|
||||
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
|
||||
&ownerID, &teamID, &embCfgJSON,
|
||||
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
|
||||
&kb.Status, &kb.Discoverable, st(&kb.CreatedAt), st(&kb.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kb.OwnerID = NullableStringPtr(ownerID)
|
||||
kb.TeamID = NullableStringPtr(teamID)
|
||||
ScanJSON(embCfgJSON, &kb.EmbeddingConfig)
|
||||
result = append(result, kb)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// makeQPlaceholders returns "?,?,?" for n items.
|
||||
func makeQPlaceholders(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(strings.Repeat("?,", n), ",")
|
||||
}
|
||||
190
server/store/sqlite/message.go
Normal file
190
server/store/sqlite/message.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package sqlite
|
||||
|
||||
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)
|
||||
m.ID = store.NewID()
|
||||
m.CreatedAt = time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO messages (id, channel_id, role, content, model, tokens_used, tool_calls,
|
||||
metadata, parent_id, sibling_index, participant_type, participant_id, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
m.ID, m.ChannelID, m.Role, m.Content, m.Model, m.TokensUsed,
|
||||
toolCallsJSON, metadataJSON,
|
||||
models.NullString(m.ParentID), m.SiblingIndex,
|
||||
m.ParticipantType, m.ParticipantID,
|
||||
m.CreatedAt.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
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 *time.Time
|
||||
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 = ?`, id).Scan(
|
||||
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
||||
&toolCallsJSON, &metadataJSON,
|
||||
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
|
||||
stN(&deletedAt), st(&m.CreatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.ParentID = NullableStringPtr(parentID)
|
||||
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
|
||||
json.Unmarshal(metadataJSON, &m.Metadata)
|
||||
m.DeletedAt = deletedAt
|
||||
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 = ? WHERE id = ?", 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 = ? 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 = ?)
|
||||
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 = ?
|
||||
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 = ? 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 = ? 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 *time.Time
|
||||
err := rows.Scan(
|
||||
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
||||
&toolCallsJSON, &metadataJSON,
|
||||
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
|
||||
stN(&deletedAt), st(&m.CreatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.ParentID = NullableStringPtr(parentID)
|
||||
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
|
||||
json.Unmarshal(metadataJSON, &m.Metadata)
|
||||
m.DeletedAt = deletedAt
|
||||
result = append(result, m)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
199
server/store/sqlite/note.go
Normal file
199
server/store/sqlite/note.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type NoteStore struct{}
|
||||
|
||||
func NewNoteStore() *NoteStore { return &NoteStore{} }
|
||||
|
||||
func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
|
||||
n.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
n.CreatedAt = now
|
||||
n.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO notes (id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
n.ID, n.UserID, n.Title, n.Content, n.FolderPath,
|
||||
ArrayToJSON(n.Tags), ToJSON(n.Metadata),
|
||||
models.NullString(n.SourceChannelID), models.NullString(n.TeamID),
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) {
|
||||
var n models.Note
|
||||
var sourceChannelID, teamID sql.NullString
|
||||
var tagsJSON, metadataJSON string
|
||||
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 = ?`, id).Scan(
|
||||
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
||||
&tagsJSON, &metadataJSON,
|
||||
&sourceChannelID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.Tags = ScanArray(tagsJSON)
|
||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
||||
n.TeamID = NullableStringPtr(teamID)
|
||||
json.Unmarshal([]byte(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, ArrayToJSON(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 = ?", 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 != "" {
|
||||
// SQLite: check JSON array membership with json_each.
|
||||
b.Where("EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)", 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()
|
||||
|
||||
return s.scanNotes(rows, total)
|
||||
}
|
||||
|
||||
// Search uses LIKE against title and content (SQLite fallback for tsvector).
|
||||
func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Note, int, error) {
|
||||
// Split query into words, build LIKE clauses for each.
|
||||
words := strings.Fields(query)
|
||||
if len(words) == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
for _, w := range words {
|
||||
pattern := "%" + w + "%"
|
||||
b.Where("(title LIKE ? OR content LIKE ?)", pattern, pattern)
|
||||
}
|
||||
|
||||
// Count
|
||||
countQ, countArgs := b.CountBuild()
|
||||
var total int
|
||||
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
|
||||
|
||||
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()
|
||||
|
||||
return s.scanNotes(rows, total)
|
||||
}
|
||||
|
||||
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] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
result, err := DB.ExecContext(ctx,
|
||||
fmt.Sprintf("DELETE FROM notes WHERE user_id = ? AND id IN (%s)",
|
||||
strings.Join(placeholders, ",")),
|
||||
args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────
|
||||
|
||||
func (s *NoteStore) scanNotes(rows *sql.Rows, total int) ([]models.Note, int, error) {
|
||||
var result []models.Note
|
||||
for rows.Next() {
|
||||
var n models.Note
|
||||
var sourceChannelID, teamID sql.NullString
|
||||
var tagsJSON, metadataJSON string
|
||||
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
||||
&tagsJSON, &metadataJSON,
|
||||
&sourceChannelID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
n.Tags = ScanArray(tagsJSON)
|
||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
||||
n.TeamID = NullableStringPtr(teamID)
|
||||
json.Unmarshal([]byte(metadataJSON), &n.Metadata)
|
||||
result = append(result, n)
|
||||
}
|
||||
return result, total, rows.Err()
|
||||
}
|
||||
410
server/store/sqlite/persona.go
Normal file
410
server/store/sqlite/persona.go
Normal file
@@ -0,0 +1,410 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
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 {
|
||||
p.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
p.CreatedAt = now
|
||||
p.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO personas (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)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, 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,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PersonaStore) GetByID(ctx context.Context, id string) (*models.Persona, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM personas WHERE id = ?", 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 = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListForUser returns all Personas visible to a user:
|
||||
// global active + team-scoped (for user's teams) + personal + shared + group-granted.
|
||||
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 = 1 AND (
|
||||
scope = 'global'
|
||||
OR (scope = 'personal' AND created_by = ?)
|
||||
OR (scope = 'team' AND owner_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = ?
|
||||
))
|
||||
OR (scope = 'personal' AND is_shared = 1)
|
||||
OR id IN (
|
||||
SELECT rg.resource_id FROM resource_grants rg
|
||||
WHERE rg.resource_type = 'persona'
|
||||
AND (
|
||||
rg.grant_scope = 'global'
|
||||
OR (rg.grant_scope = 'groups'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM group_members gm
|
||||
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
|
||||
WHERE gm.user_id = ?
|
||||
))
|
||||
)
|
||||
)
|
||||
) ORDER BY scope, name`, personaCols), userID, userID, 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 = ? AND is_active = 1 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 = ? 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 = ?", personaID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, g := range grants {
|
||||
configJSON := ToJSON(g.Config)
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO persona_grants (id, persona_id, grant_type, grant_ref, config)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
store.NewID(), 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 = ? 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, st(&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 = ? 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 = ? AND is_active = 1 AND (
|
||||
scope = 'global'
|
||||
OR (scope = 'personal' AND created_by = ?)
|
||||
OR (scope = 'team' AND owner_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = ?
|
||||
))
|
||||
OR (scope = 'personal' AND is_shared = 1)
|
||||
OR id IN (
|
||||
SELECT rg.resource_id FROM resource_grants rg
|
||||
WHERE rg.resource_type = 'persona'
|
||||
AND rg.resource_id = ?
|
||||
AND (
|
||||
rg.grant_scope = 'global'
|
||||
OR (rg.grant_scope = 'groups'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM group_members gm
|
||||
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
|
||||
WHERE gm.user_id = ?
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
)`, personaID, userID, userID, personaID, userID).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,
|
||||
st(&p.CreatedAt), st(&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,
|
||||
st(&p.CreatedAt), st(&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()
|
||||
}
|
||||
|
||||
// ── Persona-KB Bindings (v0.17.0) ───────────
|
||||
|
||||
func (s *PersonaStore) SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error {
|
||||
tx, err := DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM persona_knowledge_bases WHERE persona_id = ?", personaID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, kbID := range kbIDs {
|
||||
auto := false
|
||||
if autoSearch != nil {
|
||||
auto = autoSearch[kbID]
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO persona_knowledge_bases (persona_id, kb_id, auto_search)
|
||||
VALUES (?, ?, ?)`,
|
||||
personaID, kbID, auto)
|
||||
if err != nil {
|
||||
return fmt.Errorf("persona KB %s: %w", kbID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *PersonaStore) GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT pkb.persona_id, pkb.kb_id, pkb.auto_search, pkb.added_at,
|
||||
kb.name AS kb_name, kb.document_count, kb.chunk_count
|
||||
FROM persona_knowledge_bases pkb
|
||||
JOIN knowledge_bases kb ON kb.id = pkb.kb_id
|
||||
WHERE pkb.persona_id = ?
|
||||
ORDER BY kb.name`, personaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.PersonaKB
|
||||
for rows.Next() {
|
||||
var pkb models.PersonaKB
|
||||
if err := rows.Scan(&pkb.PersonaID, &pkb.KBID, &pkb.AutoSearch, st(&pkb.AddedAt),
|
||||
&pkb.KBName, &pkb.DocumentCount, &pkb.ChunkCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, pkb)
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]models.PersonaKB, 0)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PersonaStore) GetKBIDs(ctx context.Context, personaID string) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
"SELECT kb_id FROM persona_knowledge_bases WHERE persona_id = ?", personaID)
|
||||
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)
|
||||
}
|
||||
if ids == nil {
|
||||
ids = make([]string, 0)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
62
server/store/sqlite/policy.go
Normal file
62
server/store/sqlite/policy.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
type PolicyStore struct{}
|
||||
|
||||
func NewPolicyStore() *PolicyStore { return &PolicyStore{} }
|
||||
|
||||
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 = ?", key).Scan(&value)
|
||||
if err == sql.ErrNoRows {
|
||||
if def, ok := models.PolicyDefaults[key]; ok {
|
||||
return def, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by, updated_at = datetime('now')`,
|
||||
key, value, updatedBy, value, updatedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PolicyStore) GetAll(ctx context.Context) (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
for k, v := range models.PolicyDefaults {
|
||||
result[k] = v
|
||||
}
|
||||
rows, err := DB.QueryContext(ctx, "SELECT key, value FROM platform_policies")
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
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()
|
||||
}
|
||||
132
server/store/sqlite/pricing.go
Normal file
132
server/store/sqlite/pricing.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type PricingStore struct{}
|
||||
|
||||
func NewPricingStore() *PricingStore { return &PricingStore{} }
|
||||
|
||||
// GetForModel returns the pricing entry for a specific provider+model pair.
|
||||
func (s *PricingStore) GetForModel(ctx context.Context, providerConfigID, modelID string) (*models.PricingEntry, error) {
|
||||
var p models.PricingEntry
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, provider_config_id, model_id,
|
||||
input_per_m, output_per_m, cache_create_per_m, cache_read_per_m,
|
||||
currency, source, updated_at, updated_by
|
||||
FROM model_pricing
|
||||
WHERE provider_config_id = ? AND model_id = ?
|
||||
`, providerConfigID, modelID).Scan(
|
||||
&p.ID, &p.ProviderConfigID, &p.ModelID,
|
||||
&p.InputPerM, &p.OutputPerM, &p.CacheCreatePerM, &p.CacheReadPerM,
|
||||
&p.Currency, &p.Source, st(&p.UpdatedAt), &p.UpdatedBy,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return &p, err
|
||||
}
|
||||
|
||||
// Upsert inserts or updates a pricing entry (manual admin override).
|
||||
func (s *PricingStore) Upsert(ctx context.Context, entry *models.PricingEntry) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO model_pricing (
|
||||
id, provider_config_id, model_id,
|
||||
input_per_m, output_per_m, cache_create_per_m, cache_read_per_m,
|
||||
currency, source, updated_by, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT (provider_config_id, model_id)
|
||||
DO UPDATE SET
|
||||
input_per_m = EXCLUDED.input_per_m,
|
||||
output_per_m = EXCLUDED.output_per_m,
|
||||
cache_create_per_m = EXCLUDED.cache_create_per_m,
|
||||
cache_read_per_m = EXCLUDED.cache_read_per_m,
|
||||
currency = EXCLUDED.currency,
|
||||
source = EXCLUDED.source,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = datetime('now')
|
||||
`,
|
||||
store.NewID(), entry.ProviderConfigID, entry.ModelID,
|
||||
entry.InputPerM, entry.OutputPerM, entry.CacheCreatePerM, entry.CacheReadPerM,
|
||||
entry.Currency, entry.Source, entry.UpdatedBy,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertFromCatalog inserts or updates pricing from a catalog sync.
|
||||
// Manual overrides (source='manual') are never overwritten.
|
||||
func (s *PricingStore) UpsertFromCatalog(ctx context.Context, providerConfigID, modelID string, pricing *models.ModelPricing) error {
|
||||
if pricing == nil || (pricing.InputPerM == 0 && pricing.OutputPerM == 0) {
|
||||
return nil // No pricing to store
|
||||
}
|
||||
|
||||
currency := pricing.Currency
|
||||
if currency == "" {
|
||||
currency = "USD"
|
||||
}
|
||||
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO model_pricing (
|
||||
id, provider_config_id, model_id,
|
||||
input_per_m, output_per_m,
|
||||
currency, source, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'catalog', datetime('now'))
|
||||
ON CONFLICT (provider_config_id, model_id)
|
||||
DO UPDATE SET
|
||||
input_per_m = EXCLUDED.input_per_m,
|
||||
output_per_m = EXCLUDED.output_per_m,
|
||||
currency = EXCLUDED.currency,
|
||||
updated_at = datetime('now')
|
||||
WHERE model_pricing.source = 'catalog'
|
||||
`,
|
||||
store.NewID(), providerConfigID, modelID,
|
||||
pricing.InputPerM, pricing.OutputPerM,
|
||||
currency,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// List returns pricing entries for admin-managed providers (global + team).
|
||||
// Excludes personal BYOK providers — those are not the admin's concern.
|
||||
func (s *PricingStore) List(ctx context.Context) ([]models.PricingEntry, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT mp.id, mp.provider_config_id, mp.model_id,
|
||||
mp.input_per_m, mp.output_per_m, mp.cache_create_per_m, mp.cache_read_per_m,
|
||||
mp.currency, mp.source, mp.updated_at, mp.updated_by
|
||||
FROM model_pricing mp
|
||||
JOIN provider_configs pc ON pc.id = mp.provider_config_id
|
||||
WHERE pc.scope != 'personal'
|
||||
ORDER BY mp.provider_config_id, mp.model_id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []models.PricingEntry
|
||||
for rows.Next() {
|
||||
var p models.PricingEntry
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.ProviderConfigID, &p.ModelID,
|
||||
&p.InputPerM, &p.OutputPerM, &p.CacheCreatePerM, &p.CacheReadPerM,
|
||||
&p.Currency, &p.Source, st(&p.UpdatedAt), &p.UpdatedBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, p)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// Delete removes a pricing entry.
|
||||
func (s *PricingStore) Delete(ctx context.Context, providerConfigID, modelID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
DELETE FROM model_pricing WHERE provider_config_id = ? AND model_id = ?
|
||||
`, providerConfigID, modelID)
|
||||
return err
|
||||
}
|
||||
205
server/store/sqlite/provider.go
Normal file
205
server/store/sqlite/provider.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type ProviderStore struct{}
|
||||
|
||||
func NewProviderStore() *ProviderStore { return &ProviderStore{} }
|
||||
|
||||
const providerCols = `id, scope, owner_id, name, provider, endpoint, api_key_enc,
|
||||
key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private, created_at, updated_at`
|
||||
|
||||
func (s *ProviderStore) Create(ctx context.Context, cfg *models.ProviderConfig) error {
|
||||
cfg.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
cfg.CreatedAt = now
|
||||
cfg.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO provider_configs (id, scope, owner_id, name, provider, endpoint, api_key_enc,
|
||||
key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
cfg.ID, cfg.Scope, models.NullString(cfg.OwnerID), cfg.Name, cfg.Provider, cfg.Endpoint,
|
||||
cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, cfg.ModelDefault,
|
||||
ToJSON(cfg.Config), ToJSON(cfg.Headers), ToJSON(cfg.Settings),
|
||||
cfg.IsActive, cfg.IsPrivate,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
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 = ?", 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)
|
||||
b.Set("key_nonce", patch.KeyNonce)
|
||||
}
|
||||
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 = ?", 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 = 1 AND (
|
||||
scope = 'global'
|
||||
OR (scope = 'personal' AND owner_id = ?)
|
||||
OR (scope = 'team' AND owner_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = ?
|
||||
))
|
||||
)
|
||||
ORDER BY scope, name`, providerCols), userID, 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 = ? AND is_active = 1 AND (
|
||||
scope = 'global'
|
||||
OR (scope = 'personal' AND owner_id = ?)
|
||||
OR (scope = 'team' AND owner_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = ?
|
||||
))
|
||||
)
|
||||
)`, configID, userID, userID).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 = ? AND is_active = 1 ORDER BY name", providerCols),
|
||||
scope)
|
||||
} else {
|
||||
rows, err = DB.QueryContext(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = ? AND owner_id = ? AND is_active = 1 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, keyScope sql.NullString
|
||||
var configJSON, headersJSON, settingsJSON []byte
|
||||
err := row.Scan(
|
||||
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
|
||||
&p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault,
|
||||
&configJSON, &headersJSON, &settingsJSON,
|
||||
&p.IsActive, &p.IsPrivate, st(&p.CreatedAt), st(&p.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.OwnerID = NullableStringPtr(ownerID)
|
||||
p.ModelDefault = modelDefault.String
|
||||
p.KeyScope = keyScope.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, keyScope sql.NullString
|
||||
var configJSON, headersJSON, settingsJSON []byte
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
|
||||
&p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault,
|
||||
&configJSON, &headersJSON, &settingsJSON,
|
||||
&p.IsActive, &p.IsPrivate, st(&p.CreatedAt), st(&p.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.OwnerID = NullableStringPtr(ownerID)
|
||||
p.ModelDefault = modelDefault.String
|
||||
p.KeyScope = keyScope.String
|
||||
json.Unmarshal(configJSON, &p.Config)
|
||||
json.Unmarshal(headersJSON, &p.Headers)
|
||||
json.Unmarshal(settingsJSON, &p.Settings)
|
||||
result = append(result, p)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
106
server/store/sqlite/resource_grants.go
Normal file
106
server/store/sqlite/resource_grants.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── ResourceGrantStore ──────────────────────
|
||||
|
||||
type ResourceGrantStore struct{}
|
||||
|
||||
func NewResourceGrantStore() *ResourceGrantStore { return &ResourceGrantStore{} }
|
||||
|
||||
// Set creates or replaces the grant for a resource (upsert on unique resource_type+resource_id).
|
||||
func (s *ResourceGrantStore) Set(ctx context.Context, grant *models.ResourceGrant) error {
|
||||
groups := grant.GrantedGroups
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
grant.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO resource_grants (id, resource_type, resource_id, grant_scope, granted_groups, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (resource_type, resource_id) DO UPDATE SET
|
||||
grant_scope = excluded.grant_scope,
|
||||
granted_groups = excluded.granted_groups,
|
||||
created_by = excluded.created_by,
|
||||
updated_at = excluded.updated_at`,
|
||||
grant.ID, grant.ResourceType, grant.ResourceID, grant.GrantScope,
|
||||
ArrayToJSON(groups), grant.CreatedBy,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Fetch actual ID + timestamps (upsert may have returned existing row)
|
||||
grant.CreatedAt = now
|
||||
grant.UpdatedAt = now
|
||||
return DB.QueryRowContext(ctx,
|
||||
`SELECT id FROM resource_grants WHERE resource_type = ? AND resource_id = ?`,
|
||||
grant.ResourceType, grant.ResourceID,
|
||||
).Scan(&grant.ID)
|
||||
}
|
||||
|
||||
// Get retrieves the grant for a specific resource.
|
||||
func (s *ResourceGrantStore) Get(ctx context.Context, resourceType, resourceID string) (*models.ResourceGrant, error) {
|
||||
var rg models.ResourceGrant
|
||||
var groupsJSON string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, resource_type, resource_id, grant_scope, granted_groups, created_by,
|
||||
created_at, updated_at
|
||||
FROM resource_grants
|
||||
WHERE resource_type = ? AND resource_id = ?`,
|
||||
resourceType, resourceID,
|
||||
).Scan(&rg.ID, &rg.ResourceType, &rg.ResourceID, &rg.GrantScope,
|
||||
&groupsJSON, &rg.CreatedBy,
|
||||
st(&rg.CreatedAt), st(&rg.UpdatedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rg.GrantedGroups = ScanArray(groupsJSON)
|
||||
return &rg, nil
|
||||
}
|
||||
|
||||
// Delete removes the grant for a resource.
|
||||
func (s *ResourceGrantStore) Delete(ctx context.Context, resourceType, resourceID string) error {
|
||||
res, err := DB.ExecContext(ctx,
|
||||
"DELETE FROM resource_grants WHERE resource_type = ? AND resource_id = ?",
|
||||
resourceType, resourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserHasGroupAccess checks whether a user has group-based access to a resource.
|
||||
// SQLite: uses json_each() to expand the JSON array for group membership checks.
|
||||
func (s *ResourceGrantStore) UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error) {
|
||||
var has bool
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM resource_grants rg
|
||||
WHERE rg.resource_type = ?
|
||||
AND rg.resource_id = ?
|
||||
AND (
|
||||
rg.grant_scope = 'global'
|
||||
OR (
|
||||
rg.grant_scope = 'groups'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM group_members gm
|
||||
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
|
||||
WHERE gm.user_id = ?
|
||||
)
|
||||
)
|
||||
)
|
||||
)`, resourceType, resourceID, userID).Scan(&has)
|
||||
return has, err
|
||||
}
|
||||
34
server/store/sqlite/stores.go
Normal file
34
server/store/sqlite/stores.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// NewStores creates all SQLite 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(),
|
||||
Usage: NewUsageStore(),
|
||||
Pricing: NewPricingStore(),
|
||||
Extensions: NewExtensionStore(),
|
||||
Attachments: NewAttachmentStore(),
|
||||
KnowledgeBases: NewKnowledgeBaseStore(),
|
||||
Groups: NewGroupStore(),
|
||||
ResourceGrants: NewResourceGrantStore(),
|
||||
}
|
||||
}
|
||||
205
server/store/sqlite/team.go
Normal file
205
server/store/sqlite/team.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type TeamStore struct{}
|
||||
|
||||
func NewTeamStore() *TeamStore { return &TeamStore{} }
|
||||
|
||||
func (s *TeamStore) Create(ctx context.Context, t *models.Team) error {
|
||||
settingsJSON := ToJSON(t.Settings)
|
||||
t.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
t.CreatedAt = now
|
||||
t.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO teams (id, name, description, created_by, is_active, settings, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ID, t.Name, t.Description, t.CreatedBy, t.IsActive, settingsJSON,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
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 = ?`, id).Scan(
|
||||
&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
|
||||
&settingsJSON, st(&t.CreatedAt), st(&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 = ?", 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, st(&t.CreatedAt), st(&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 (id, team_id, user_id, role) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role`,
|
||||
store.NewID(), 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 = ? AND user_id = ?",
|
||||
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 = ? WHERE team_id = ? AND user_id = ?",
|
||||
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 = ?
|
||||
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 = ? AND tm.user_id = ?`, 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 = ?", 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 = ? AND user_id = ?",
|
||||
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 = ? AND user_id = ?)",
|
||||
teamID, userID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// unused but keeping for reference
|
||||
var _ = fmt.Sprintf
|
||||
242
server/store/sqlite/usage.go
Normal file
242
server/store/sqlite/usage.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type UsageStore struct{}
|
||||
|
||||
func NewUsageStore() *UsageStore { return &UsageStore{} }
|
||||
|
||||
// Log inserts a usage entry.
|
||||
func (s *UsageStore) Log(ctx context.Context, entry *models.UsageEntry) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO usage_log (
|
||||
id, channel_id, user_id, provider_config_id, provider_scope,
|
||||
model_id, role,
|
||||
prompt_tokens, completion_tokens,
|
||||
cache_creation_tokens, cache_read_tokens,
|
||||
cost_input, cost_output
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
store.NewID(), entry.ChannelID, entry.UserID, entry.ProviderConfigID, entry.ProviderScope,
|
||||
entry.ModelID, entry.Role,
|
||||
entry.PromptTokens, entry.CompletionTokens,
|
||||
entry.CacheCreationTokens, entry.CacheReadTokens,
|
||||
entry.CostInput, entry.CostOutput,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// QueryByUser returns aggregated usage for a specific user.
|
||||
func (s *UsageStore) QueryByUser(ctx context.Context, userID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
||||
where := []string{"user_id = ?"}
|
||||
args := []interface{}{userID}
|
||||
return s.query(ctx, where, args, opts)
|
||||
}
|
||||
|
||||
// QueryByUserPersonal returns aggregated usage for a user's own (BYOK) providers only.
|
||||
func (s *UsageStore) QueryByUserPersonal(ctx context.Context, userID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
||||
where := []string{
|
||||
"user_id = ?",
|
||||
"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = ?)",
|
||||
}
|
||||
args := []interface{}{userID, userID}
|
||||
return s.query(ctx, where, args, opts)
|
||||
}
|
||||
|
||||
// QueryByTeam returns aggregated usage for all members of a team (admin view).
|
||||
func (s *UsageStore) QueryByTeam(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
||||
where := []string{"user_id IN (SELECT user_id FROM team_members WHERE team_id = ?)"}
|
||||
args := []interface{}{teamID}
|
||||
return s.query(ctx, where, args, opts)
|
||||
}
|
||||
|
||||
// QueryByTeamProviders returns usage against providers owned by this team.
|
||||
// Used by team admins to see how their team's API keys are being consumed.
|
||||
func (s *UsageStore) QueryByTeamProviders(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
||||
where := []string{"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'team' AND owner_id = ?)"}
|
||||
args := []interface{}{teamID}
|
||||
return s.query(ctx, where, args, opts)
|
||||
}
|
||||
|
||||
// QueryByModel returns aggregated usage grouped by model.
|
||||
func (s *UsageStore) QueryByModel(ctx context.Context, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
||||
return s.query(ctx, nil, nil, opts)
|
||||
}
|
||||
|
||||
// GetTotals returns aggregate totals (admin view — excludes BYOK by default).
|
||||
func (s *UsageStore) GetTotals(ctx context.Context, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
|
||||
where, args := s.buildFilters(nil, nil, opts)
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COALESCE(SUM(prompt_tokens), 0),
|
||||
COALESCE(SUM(completion_tokens), 0),
|
||||
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
|
||||
FROM usage_log
|
||||
WHERE %s
|
||||
`, strings.Join(where, " AND "))
|
||||
|
||||
var t models.UsageTotals
|
||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
||||
&t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
|
||||
)
|
||||
return &t, err
|
||||
}
|
||||
|
||||
// GetTeamProviderTotals returns aggregate totals for providers owned by a team.
|
||||
func (s *UsageStore) GetTeamProviderTotals(ctx context.Context, teamID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
|
||||
baseWhere := []string{"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'team' AND owner_id = ?)"}
|
||||
baseArgs := []interface{}{teamID}
|
||||
where, args := s.buildFilters(baseWhere, baseArgs, opts)
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COALESCE(SUM(prompt_tokens), 0),
|
||||
COALESCE(SUM(completion_tokens), 0),
|
||||
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
|
||||
FROM usage_log
|
||||
WHERE %s
|
||||
`, strings.Join(where, " AND "))
|
||||
|
||||
var t models.UsageTotals
|
||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
||||
&t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
|
||||
)
|
||||
return &t, err
|
||||
}
|
||||
|
||||
// GetPersonalTotals returns aggregate totals for a user's own provider usage.
|
||||
// Only includes usage against personal (BYOK) providers — global provider
|
||||
// costs are the org's responsibility, not the user's.
|
||||
func (s *UsageStore) GetPersonalTotals(ctx context.Context, userID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
|
||||
baseWhere := []string{
|
||||
"user_id = ?",
|
||||
"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = ?)",
|
||||
}
|
||||
baseArgs := []interface{}{userID, userID}
|
||||
opts.ExcludeBYOK = false
|
||||
where, args := s.buildFilters(baseWhere, baseArgs, opts)
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COALESCE(SUM(prompt_tokens), 0),
|
||||
COALESCE(SUM(completion_tokens), 0),
|
||||
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
|
||||
FROM usage_log
|
||||
WHERE %s
|
||||
`, strings.Join(where, " AND "))
|
||||
|
||||
var t models.UsageTotals
|
||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
||||
&t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
|
||||
)
|
||||
return &t, err
|
||||
}
|
||||
|
||||
// CountRecentByRole counts how many usage entries a user has for a given
|
||||
// role within the specified duration. Used for rate limiting utility calls.
|
||||
func (s *UsageStore) CountRecentByRole(ctx context.Context, userID, role string, since time.Time) (int, error) {
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM usage_log
|
||||
WHERE user_id = ? AND role = ? AND created_at >= ?
|
||||
`, userID, role, since).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────
|
||||
|
||||
func (s *UsageStore) buildFilters(baseWhere []string, baseArgs []interface{}, opts store.UsageQueryOptions) ([]string, []interface{}) {
|
||||
where := append([]string{}, baseWhere...)
|
||||
args := append([]interface{}{}, baseArgs...)
|
||||
|
||||
if opts.ExcludeBYOK {
|
||||
where = append(where, "provider_scope != ?")
|
||||
args = append(args, "personal")
|
||||
}
|
||||
if opts.Since != nil {
|
||||
where = append(where, "created_at >= ?")
|
||||
args = append(args, *opts.Since)
|
||||
}
|
||||
if opts.Until != nil {
|
||||
where = append(where, "created_at < ?")
|
||||
args = append(args, *opts.Until)
|
||||
}
|
||||
|
||||
if len(where) == 0 {
|
||||
where = append(where, "1=1")
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
func (s *UsageStore) query(ctx context.Context, baseWhere []string, baseArgs []interface{}, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
|
||||
where, args := s.buildFilters(baseWhere, baseArgs, opts)
|
||||
|
||||
// Determine GROUP BY expression and label
|
||||
groupExpr := "model_id"
|
||||
labelExpr := "model_id"
|
||||
switch opts.GroupBy {
|
||||
case "day":
|
||||
groupExpr = "DATE(created_at)"
|
||||
labelExpr = "DATE(created_at)"
|
||||
case "user":
|
||||
groupExpr = "user_id"
|
||||
labelExpr = "user_id"
|
||||
case "provider":
|
||||
groupExpr = "provider_config_id"
|
||||
labelExpr = "provider_config_id"
|
||||
case "model":
|
||||
groupExpr = "model_id"
|
||||
labelExpr = "model_id"
|
||||
default:
|
||||
groupExpr = "model_id"
|
||||
labelExpr = "model_id"
|
||||
}
|
||||
|
||||
limit := opts.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
SELECT
|
||||
%s AS group_key,
|
||||
%s AS label,
|
||||
COUNT(*) AS requests,
|
||||
COALESCE(SUM(prompt_tokens), 0) AS input_tokens,
|
||||
COALESCE(SUM(completion_tokens), 0) AS output_tokens,
|
||||
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0) AS total_cost
|
||||
FROM usage_log
|
||||
WHERE %s
|
||||
GROUP BY %s
|
||||
ORDER BY total_cost DESC
|
||||
LIMIT %d
|
||||
`, groupExpr, labelExpr, strings.Join(where, " AND "), groupExpr, limit)
|
||||
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []models.UsageAggregate
|
||||
for rows.Next() {
|
||||
var a models.UsageAggregate
|
||||
if err := rows.Scan(&a.GroupKey, &a.Label, &a.Requests, &a.InputTokens, &a.OutputTokens, &a.TotalCost); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, a)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
222
server/store/sqlite/user.go
Normal file
222
server/store/sqlite/user.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package sqlite
|
||||
|
||||
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 {
|
||||
u.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
u.CreatedAt = now
|
||||
u.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO users (id, username, email, password_hash, display_name, role, is_active, settings, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, ToJSON(u.Settings),
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
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) {
|
||||
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 LOWER(username) = LOWER(?)`, username).Scan(
|
||||
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
|
||||
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&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) GetByEmail(ctx context.Context, email 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 LOWER(email) = LOWER(?)`, email).Scan(
|
||||
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
|
||||
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&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) 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 LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)`, login, login).Scan(
|
||||
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
|
||||
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&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 = ?", 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, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&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 = datetime('now') WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error {
|
||||
_, err := DB.ExecContext(ctx, "UPDATE users SET is_active = ? WHERE id = ?", 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 (id, user_id, token_hash, expires_at)
|
||||
VALUES (?, ?, ?, ?)`, store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt))
|
||||
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 = ? AND revoked_at IS NULL AND expires_at > datetime('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 = datetime('now') WHERE token_hash = ?", tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *UserStore) RevokeAllRefreshTokens(ctx context.Context, userID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
"UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE user_id = ? 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 < datetime('now', '-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 = ?`, col), val).Scan(
|
||||
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
|
||||
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.DisplayName = NullableString(displayName)
|
||||
u.AvatarURL = NullableString(avatarURL)
|
||||
ScanJSON(settingsJSON, &u.Settings)
|
||||
return &u, nil
|
||||
}
|
||||
155
server/store/sqlite/user_settings.go
Normal file
155
server/store/sqlite/user_settings.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
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 = ? 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, st(&s.CreatedAt), st(&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 = ? AND hidden = 1",
|
||||
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 (id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, model_id)
|
||||
DO UPDATE SET
|
||||
hidden = COALESCE(excluded.hidden, user_model_settings.hidden),
|
||||
preferred_temperature = COALESCE(excluded.preferred_temperature, user_model_settings.preferred_temperature),
|
||||
preferred_max_tokens = COALESCE(excluded.preferred_max_tokens, user_model_settings.preferred_max_tokens),
|
||||
sort_order = COALESCE(excluded.sort_order, user_model_settings.sort_order)`,
|
||||
store.NewID(), 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
|
||||
}
|
||||
|
||||
// Upsert each model individually
|
||||
for _, modelID := range modelIDs {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO user_model_settings (id, user_id, model_id, hidden)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = excluded.hidden`,
|
||||
store.NewID(), 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
|
||||
@@ -3,11 +3,25 @@ package treepath
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// UpdateCursor upserts the channel_cursors row for a user.
|
||||
func UpdateCursor(channelID, userID, messageID string) error {
|
||||
if database.IsSQLite() {
|
||||
// SQLite: id TEXT PRIMARY KEY has no default generator; supply one.
|
||||
// Use excluded.active_leaf_id so ON CONFLICT works cleanly.
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO channel_cursors (id, channel_id, user_id, active_leaf_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (channel_id, user_id) DO UPDATE SET
|
||||
active_leaf_id = excluded.active_leaf_id, updated_at = datetime('now')
|
||||
`, uuid.New().String(), channelID, userID, messageID)
|
||||
return err
|
||||
}
|
||||
// Postgres: id has DEFAULT gen_random_uuid(); $3 is referenced twice.
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -22,15 +36,15 @@ func UpdateCursor(channelID, userID, messageID string) error {
|
||||
func NextSiblingIndex(channelID string, parentID *string) int {
|
||||
var maxIdx sql.NullInt64
|
||||
if parentID == nil {
|
||||
database.DB.QueryRow(`
|
||||
database.DB.QueryRow(database.Q(`
|
||||
SELECT MAX(sibling_index) FROM messages
|
||||
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
||||
`, channelID).Scan(&maxIdx)
|
||||
`), channelID).Scan(&maxIdx)
|
||||
} else {
|
||||
database.DB.QueryRow(`
|
||||
database.DB.QueryRow(database.Q(`
|
||||
SELECT MAX(sibling_index) FROM messages
|
||||
WHERE parent_id = $1 AND deleted_at IS NULL
|
||||
`, *parentID).Scan(&maxIdx)
|
||||
`), *parentID).Scan(&maxIdx)
|
||||
}
|
||||
if !maxIdx.Valid {
|
||||
return 0
|
||||
|
||||
@@ -46,17 +46,17 @@ func GetActiveLeaf(channelID, userID string) (*string, error) {
|
||||
var leafID *string
|
||||
|
||||
// Try cursor first
|
||||
err := database.DB.QueryRow(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT active_leaf_id FROM channel_cursors
|
||||
WHERE channel_id = $1 AND user_id = $2
|
||||
`, channelID, userID).Scan(&leafID)
|
||||
`), channelID, userID).Scan(&leafID)
|
||||
|
||||
if err == nil && leafID != nil {
|
||||
// Verify the leaf still exists and isn't deleted
|
||||
var exists bool
|
||||
database.DB.QueryRow(`
|
||||
database.DB.QueryRow(database.Q(`
|
||||
SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND deleted_at IS NULL)
|
||||
`, *leafID).Scan(&exists)
|
||||
`), *leafID).Scan(&exists)
|
||||
if exists {
|
||||
return leafID, nil
|
||||
}
|
||||
@@ -64,11 +64,11 @@ func GetActiveLeaf(channelID, userID string) (*string, error) {
|
||||
|
||||
// Fallback: latest live message in channel
|
||||
var fallbackID string
|
||||
err = database.DB.QueryRow(`
|
||||
err = database.DB.QueryRow(database.Q(`
|
||||
SELECT id FROM messages
|
||||
WHERE channel_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`, channelID).Scan(&fallbackID)
|
||||
`), channelID).Scan(&fallbackID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // empty channel
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func GetActivePath(channelID, userID string) ([]PathMessage, error) {
|
||||
|
||||
// GetPathToLeaf walks from a specific leaf up to root, returning root-first.
|
||||
func GetPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
|
||||
rows, err := database.DB.Query(`
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
WITH RECURSIVE path AS (
|
||||
-- Anchor: start at the leaf
|
||||
SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata,
|
||||
@@ -119,7 +119,7 @@ func GetPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
|
||||
participant_type, participant_id, sibling_index, created_at
|
||||
FROM path
|
||||
ORDER BY depth DESC
|
||||
`, leafID, channelID)
|
||||
`), leafID, channelID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetPathToLeaf: %w", err)
|
||||
}
|
||||
|
||||
@@ -12,15 +12,15 @@ import (
|
||||
func GetSiblingCount(channelID string, parentID *string) int {
|
||||
var count int
|
||||
if parentID == nil {
|
||||
database.DB.QueryRow(`
|
||||
database.DB.QueryRow(database.Q(`
|
||||
SELECT COUNT(*) FROM messages
|
||||
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
||||
`, channelID).Scan(&count)
|
||||
`), channelID).Scan(&count)
|
||||
} else {
|
||||
database.DB.QueryRow(`
|
||||
database.DB.QueryRow(database.Q(`
|
||||
SELECT COUNT(*) FROM messages
|
||||
WHERE parent_id = $1 AND deleted_at IS NULL
|
||||
`, *parentID).Scan(&count)
|
||||
`), *parentID).Scan(&count)
|
||||
}
|
||||
if count == 0 {
|
||||
count = 1
|
||||
@@ -34,10 +34,10 @@ func GetSiblings(messageID string) ([]SiblingInfo, int, error) {
|
||||
// First get the parent_id of the target message
|
||||
var parentID *string
|
||||
var channelID string
|
||||
err := database.DB.QueryRow(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT parent_id, channel_id FROM messages
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`, messageID).Scan(&parentID, &channelID)
|
||||
`), messageID).Scan(&parentID, &channelID)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("message not found: %w", err)
|
||||
}
|
||||
@@ -45,19 +45,19 @@ func GetSiblings(messageID string) ([]SiblingInfo, int, error) {
|
||||
var rows *sql.Rows
|
||||
if parentID == nil {
|
||||
// Root messages: siblings are other roots in the same channel
|
||||
rows, err = database.DB.Query(`
|
||||
SELECT id, role, model, sibling_index, LEFT(content, 80), created_at
|
||||
rows, err = database.DB.Query(database.Q(`
|
||||
SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at
|
||||
FROM messages
|
||||
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
||||
ORDER BY sibling_index, created_at
|
||||
`, channelID)
|
||||
`), channelID)
|
||||
} else {
|
||||
rows, err = database.DB.Query(`
|
||||
SELECT id, role, model, sibling_index, LEFT(content, 80), created_at
|
||||
rows, err = database.DB.Query(database.Q(`
|
||||
SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at
|
||||
FROM messages
|
||||
WHERE parent_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY sibling_index, created_at
|
||||
`, *parentID)
|
||||
`), *parentID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -85,7 +85,7 @@ func GetSiblings(messageID string) ([]SiblingInfo, int, error) {
|
||||
// Used when switching branches: clicking a mid-tree sibling navigates to its leaf.
|
||||
func FindLeafFromMessage(messageID string) (string, error) {
|
||||
var leafID string
|
||||
err := database.DB.QueryRow(`
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id, 0 AS depth
|
||||
FROM messages
|
||||
@@ -105,7 +105,7 @@ func FindLeafFromMessage(messageID string) (string, error) {
|
||||
SELECT id FROM descendants
|
||||
ORDER BY depth DESC
|
||||
LIMIT 1
|
||||
`, messageID).Scan(&leafID)
|
||||
`), messageID).Scan(&leafID)
|
||||
|
||||
if err != nil {
|
||||
return messageID, nil // if anything fails, just use the message itself
|
||||
|
||||
Reference in New Issue
Block a user