Changeset 0.6.0 (#36)

This commit is contained in:
2026-02-20 22:24:47 +00:00
parent 30d0c11219
commit 925b70f98c
34 changed files with 2575 additions and 637 deletions

View File

@@ -12,6 +12,12 @@ type Config struct {
DatabaseURL string
JWTSecret string
Environment string
BasePath string // URL path prefix (e.g. "/dev", "/test", or "" for root)
// Admin bootstrap (optional — set via K8s secret)
AdminUsername string
AdminPassword string
AdminEmail string
}
// Load reads configuration from environment variables.
@@ -21,13 +27,34 @@ func Load() *Config {
_ = godotenv.Load()
return &Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: getEnv("DATABASE_URL", ""),
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
Environment: getEnv("ENVIRONMENT", "development"),
Port: getEnv("PORT", "8080"),
DatabaseURL: getEnv("DATABASE_URL", ""),
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
Environment: getEnv("ENVIRONMENT", "development"),
BasePath: sanitizeBasePath(getEnv("BASE_PATH", "")),
AdminUsername: getEnv("SWITCHBOARD_ADMIN_USERNAME", ""),
AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""),
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
}
}
// sanitizeBasePath ensures the path starts with / and doesn't end with /.
// Empty string means root (no prefix).
func sanitizeBasePath(p string) string {
if p == "" || p == "/" {
return ""
}
// Ensure leading slash
if p[0] != '/' {
p = "/" + p
}
// Strip trailing slash
for len(p) > 1 && p[len(p)-1] == '/' {
p = p[:len(p)-1]
}
return p
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

40
server/go.sum Normal file
View File

@@ -0,0 +1,40 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=

View File

@@ -278,6 +278,44 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
}
// ── Public Settings (for any authenticated user) ──
var publicSettingKeys = map[string]bool{
"banner": true,
"user_providers_enabled": true,
"registration_enabled": true,
"registration_default_state": true,
"banner_presets": true,
}
func (h *AdminHandler) PublicSettings(c *gin.Context) {
rows, err := database.DB.Query(`
SELECT key, value::text FROM global_settings ORDER BY key
`)
if err != nil {
c.JSON(http.StatusOK, gin.H{"settings": []interface{}{}})
return
}
defer rows.Close()
settings := make([]globalSettingResponse, 0)
for rows.Next() {
var key, valueRaw string
if err := rows.Scan(&key, &valueRaw); err != nil {
continue
}
if !publicSettingKeys[key] {
continue
}
s := globalSettingResponse{Key: key}
s.Value = make(map[string]interface{})
_ = json.Unmarshal([]byte(valueRaw), &s.Value)
settings = append(settings, s)
}
c.JSON(http.StatusOK, gin.H{"settings": settings})
}
// ── List Global Settings ────────────────────
func (h *AdminHandler) ListGlobalSettings(c *gin.Context) {
@@ -369,8 +407,8 @@ func (h *AdminHandler) GetStats(c *gin.Context) {
queries := map[string]string{
"total_users": "SELECT COUNT(*) FROM users",
"active_users": "SELECT COUNT(*) FROM users WHERE is_active = true",
"total_chats": "SELECT COUNT(*) FROM chats",
"total_messages": "SELECT COUNT(*) FROM chat_messages",
"total_channels": "SELECT COUNT(*) FROM channels",
"total_messages": "SELECT COUNT(*) FROM messages",
"api_configs": "SELECT COUNT(*) FROM api_configs",
}
@@ -438,8 +476,8 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
var id string
err := database.DB.QueryRow(`
INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id)
VALUES ($1, $2, $3, $4, $5, NULL)
INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id, is_global)
VALUES ($1, $2, $3, $4, $5, NULL, true)
RETURNING id
`, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault).Scan(&id)
if err != nil {
@@ -666,6 +704,28 @@ func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "model updated"})
}
// BulkUpdateModels enables or disables all models at once
func (h *AdminHandler) BulkUpdateModels(c *gin.Context) {
var req struct {
IsEnabled bool `json:"is_enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result, err := database.DB.Exec(
`UPDATE model_configs SET is_enabled = $1, updated_at = NOW()`,
req.IsEnabled,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update models"})
return
}
rows, _ := result.RowsAffected()
c.JSON(http.StatusOK, gin.H{"message": "models updated", "count": rows})
}
func (h *AdminHandler) DeleteModelConfig(c *gin.Context) {
modelID := c.Param("id")
result, err := database.DB.Exec(`DELETE FROM model_configs WHERE id = $1`, modelID)

View File

@@ -89,8 +89,8 @@ func TestCompletionHandlerMissingFields(t *testing.T) {
name string
body string
}{
{"missing chat_id", `{"content":"hello"}`},
{"missing content", `{"chat_id":"abc"}`},
{"missing channel_id", `{"content":"hello"}`},
{"missing content", `{"channel_id":"abc"}`},
{"empty body", `{}`},
}

View File

@@ -6,7 +6,9 @@ import (
"database/sql"
"encoding/hex"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
@@ -91,8 +93,12 @@ func (h *AuthHandler) Register(c *gin.Context) {
_ = database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount)
isFirstUser := userCount == 0
// If not first user, check if registration is enabled
if !isFirstUser {
// First-user-becomes-admin only when no env admin is configured
envAdminSet := os.Getenv("SWITCHBOARD_ADMIN_USERNAME") != ""
promoteFirst := isFirstUser && !envAdminSet
// If not first user (or env admin handles bootstrap), check registration
if !promoteFirst {
if !IsRegistrationEnabled() {
c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"})
return
@@ -106,19 +112,25 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
// First user gets admin role
// Determine role and active state
role := "user"
if isFirstUser {
isActive := true
if promoteFirst {
role = "admin"
} else {
// Apply registration default state
if GetRegistrationDefaultState() == "pending" {
isActive = false
}
}
// Insert user
var user userResponse
err = database.DB.QueryRow(`
INSERT INTO users (username, email, password_hash, role)
VALUES ($1, $2, $3, $4)
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, display_name, role
`, req.Username, req.Email, string(hash), role).Scan(
`, req.Username, req.Email, string(hash), role, isActive).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role,
)
if err != nil {
@@ -134,6 +146,15 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
// If account is pending, don't generate tokens
if !isActive {
c.JSON(http.StatusCreated, gin.H{
"message": "Account created and pending admin approval",
"pending": true,
})
return
}
// Generate tokens
resp, err := h.generateTokenPair(user)
if err != nil {
@@ -152,8 +173,8 @@ func IsRegistrationEnabled() bool {
}
var enabled bool
err := database.DB.QueryRow(`
SELECT COALESCE((value->>'enabled')::boolean, true)
FROM global_settings WHERE key = 'registration'
SELECT COALESCE((value->>'value')::boolean, true)
FROM global_settings WHERE key = 'registration_enabled'
`).Scan(&enabled)
if err != nil {
return true // Default to open if setting missing
@@ -161,6 +182,75 @@ func IsRegistrationEnabled() bool {
return enabled
}
// GetRegistrationDefaultState returns "active" or "pending".
func GetRegistrationDefaultState() string {
if database.DB == nil {
return "active"
}
var state string
err := database.DB.QueryRow(`
SELECT COALESCE(value->>'value', 'active')
FROM global_settings WHERE key = 'registration_default_state'
`).Scan(&state)
if err != nil || state == "" {
return "active"
}
return state
}
// BootstrapAdmin creates or updates the admin user from environment variables.
// This runs on every startup, so changing the K8s secret + restarting resets the password.
// Handles both username and email conflicts (e.g. admin username changed between deploys).
func BootstrapAdmin(cfg *config.Config) {
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
return
}
if database.DB == nil {
return
}
email := cfg.AdminEmail
if email == "" {
email = cfg.AdminUsername + "@localhost"
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcryptCost)
if err != nil {
log.Printf("⚠ Failed to hash admin password: %v", err)
return
}
// Try upsert by username (common case: same username, new password)
_, err = database.DB.Exec(`
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ($1, $2, $3, 'admin', true)
ON CONFLICT (username) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
email = EXCLUDED.email,
role = 'admin',
is_active = true
`, cfg.AdminUsername, email, string(hash))
if err != nil && strings.Contains(err.Error(), "duplicate key") {
// Email conflict — admin username was changed in config but email
// already belongs to old admin row. Update that row instead.
_, err = database.DB.Exec(`
UPDATE users SET
username = $1,
password_hash = $3,
role = 'admin',
is_active = true
WHERE email = $2
`, cfg.AdminUsername, email, string(hash))
}
if err != nil {
log.Printf("⚠ Admin bootstrap failed: %v", err)
} else {
log.Printf("✅ Admin user '%s' bootstrapped from environment", cfg.AdminUsername)
}
}
// ── Login ───────────────────────────────────
func (h *AuthHandler) Login(c *gin.Context) {
@@ -195,7 +285,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
}
if !isActive {
c.JSON(http.StatusForbidden, gin.H{"error": "account disabled"})
c.JSON(http.StatusForbidden, gin.H{"error": "account is pending admin approval"})
return
}

View File

@@ -14,8 +14,10 @@ import (
// ── Request / Response types ────────────────
type createChatRequest struct {
type createChannelRequest struct {
Title string `json:"title" binding:"required,max=500"`
Type string `json:"type,omitempty"` // direct (default), group, channel
Description string `json:"description,omitempty"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
APIConfigID *string `json:"api_config_id,omitempty"`
@@ -23,8 +25,9 @@ type createChatRequest struct {
Tags []string `json:"tags,omitempty"`
}
type updateChatRequest struct {
type updateChannelRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Model *string `json:"model,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
APIConfigID *string `json:"api_config_id,omitempty"`
@@ -34,10 +37,12 @@ type updateChatRequest struct {
Tags []string `json:"tags,omitempty"`
}
type chatResponse struct {
type channelResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Type string `json:"type"`
Description *string `json:"description"`
Model *string `json:"model"`
APIConfigID *string `json:"api_config_id"`
SystemPrompt *string `json:"system_prompt"`
@@ -58,12 +63,12 @@ type paginatedResponse struct {
TotalPages int `json:"total_pages"`
}
// ChatHandler holds dependencies for chat endpoints.
type ChatHandler struct{}
// ChannelHandler holds dependencies for channel endpoints.
type ChannelHandler struct{}
// NewChatHandler creates a new chat handler.
func NewChatHandler() *ChatHandler {
return &ChatHandler{}
// NewChannelHandler creates a new channel handler.
func NewChannelHandler() *ChannelHandler {
return &ChannelHandler{}
}
// ── Helpers ─────────────────────────────────
@@ -93,46 +98,59 @@ func parsePagination(c *gin.Context) (page, perPage, offset int) {
return
}
// ── List Chats ──────────────────────────────
// ── List Channels ───────────────────────────
func (h *ChatHandler) ListChats(c *gin.Context) {
func (h *ChannelHandler) ListChannels(c *gin.Context) {
userID := getUserID(c)
page, perPage, offset := parsePagination(c)
// Optional filters
archived := c.DefaultQuery("archived", "false")
folder := c.Query("folder")
channelType := c.DefaultQuery("type", "") // empty = all types
// Count total
var total int
countQuery := `SELECT COUNT(*) FROM chats WHERE user_id = $1 AND is_archived = $2`
countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2`
countArgs := []interface{}{userID, archived == "true"}
argN := 3
if channelType != "" {
countQuery += ` AND type = $` + strconv.Itoa(argN)
countArgs = append(countArgs, channelType)
argN++
}
if folder != "" {
countQuery += ` AND folder = $3`
countQuery += ` AND folder = $` + strconv.Itoa(argN)
countArgs = append(countArgs, folder)
argN++
}
var total int
if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count chats"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"})
return
}
// Fetch chats with message count
// Fetch channels with message count
query := `
SELECT c.id, c.user_id, c.title, c.model, c.api_config_id,
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.api_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
FROM chats c
FROM channels c
LEFT JOIN (
SELECT chat_id, COUNT(*) AS cnt FROM chat_messages GROUP BY chat_id
) mc ON mc.chat_id = c.id
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
) mc ON mc.channel_id = c.id
WHERE c.user_id = $1 AND c.is_archived = $2`
args := []interface{}{userID, archived == "true"}
argN := 3
argN = 3
if channelType != "" {
query += ` AND c.type = $` + strconv.Itoa(argN)
args = append(args, channelType)
argN++
}
if folder != "" {
query += ` AND c.folder = $` + strconv.Itoa(argN)
args = append(args, folder)
@@ -145,34 +163,34 @@ func (h *ChatHandler) ListChats(c *gin.Context) {
rows, err := database.DB.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list chats"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
return
}
defer rows.Close()
chats := make([]chatResponse, 0)
channels := make([]channelResponse, 0)
for rows.Next() {
var chat chatResponse
var ch channelResponse
var tags []string
err := rows.Scan(
&chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID,
&chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder,
&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),
&chat.MessageCount, &chat.CreatedAt, &chat.UpdatedAt,
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan chat"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan channel"})
return
}
if tags == nil {
tags = []string{}
}
chat.Tags = tags
chats = append(chats, chat)
ch.Tags = tags
channels = append(channels, ch)
}
c.JSON(http.StatusOK, paginatedResponse{
Data: chats,
Data: channels,
Page: page,
PerPage: perPage,
Total: total,
@@ -180,12 +198,12 @@ func (h *ChatHandler) ListChats(c *gin.Context) {
})
}
// ── Create Chat ─────────────────────────────
// ── Create Channel ──────────────────────────
func (h *ChatHandler) CreateChat(c *gin.Context) {
func (h *ChannelHandler) CreateChannel(c *gin.Context) {
userID := getUserID(c)
var req createChatRequest
var req createChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -195,88 +213,110 @@ func (h *ChatHandler) CreateChat(c *gin.Context) {
req.Tags = []string{}
}
var chat chatResponse
// Default type is "direct" (1:1 AI chat)
channelType := req.Type
if channelType == "" {
channelType = "direct"
}
var ch channelResponse
var tags []string
err := database.DB.QueryRow(`
INSERT INTO chats (user_id, title, model, system_prompt, api_config_id, folder, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, user_id, title, model, api_config_id, system_prompt,
INSERT INTO channels (user_id, title, type, description, model, system_prompt, api_config_id, folder, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, title, type, description, model, api_config_id, system_prompt,
is_archived, is_pinned, folder, tags, created_at, updated_at
`, userID, req.Title, req.Model, req.SystemPrompt, req.APIConfigID,
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
req.Folder, pq.Array(req.Tags),
).Scan(
&chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID,
&chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder,
pq.Array(&tags), &chat.CreatedAt, &chat.UpdatedAt,
&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.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create chat"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
return
}
if tags == nil {
tags = []string{}
}
chat.Tags = tags
chat.MessageCount = 0
ch.Tags = tags
ch.MessageCount = 0
c.JSON(http.StatusCreated, chat)
// 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)
// Auto-create channel_model if model specified
if req.Model != "" {
_, _ = database.DB.Exec(`
INSERT INTO channel_models (channel_id, model_id, api_config_id, is_default)
VALUES ($1, $2, $3, true)
ON CONFLICT DO NOTHING
`, ch.ID, req.Model, req.APIConfigID)
}
c.JSON(http.StatusCreated, ch)
}
// ── Get Chat ────────────────────────────────
// ── Get Channel ─────────────────────────────
func (h *ChatHandler) GetChat(c *gin.Context) {
func (h *ChannelHandler) GetChannel(c *gin.Context) {
userID := getUserID(c)
chatID := c.Param("id")
channelID := c.Param("id")
var chat chatResponse
var ch channelResponse
var tags []string
err := database.DB.QueryRow(`
SELECT c.id, c.user_id, c.title, c.model, c.api_config_id,
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.api_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
FROM chats c
FROM channels c
LEFT JOIN (
SELECT chat_id, COUNT(*) AS cnt FROM chat_messages GROUP BY chat_id
) mc ON mc.chat_id = c.id
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
`, chatID, userID).Scan(
&chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID,
&chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder,
`, 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),
&chat.MessageCount, &chat.CreatedAt, &chat.UpdatedAt,
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"})
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get chat"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get channel"})
return
}
if tags == nil {
tags = []string{}
}
chat.Tags = tags
ch.Tags = tags
c.JSON(http.StatusOK, chat)
c.JSON(http.StatusOK, ch)
}
// ── Update Chat ─────────────────────────────
// ── Update Channel ──────────────────────────
func (h *ChatHandler) UpdateChat(c *gin.Context) {
func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
userID := getUserID(c)
chatID := c.Param("id")
if database.DB == nil {
channelID := c.Param("id")
if database.DB == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"})
return
}
var req updateChatRequest
var req updateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -284,13 +324,13 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) {
// Verify ownership
var ownerID string
err := database.DB.QueryRow(`SELECT user_id FROM chats WHERE id = $1`, chatID).Scan(&ownerID)
err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"})
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your chat"})
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
@@ -308,6 +348,9 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) {
if req.Title != nil {
addClause("title", *req.Title)
}
if req.Description != nil {
addClause("description", *req.Description)
}
if req.Model != nil {
addClause("model", *req.Model)
}
@@ -335,7 +378,7 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) {
return
}
query := "UPDATE chats SET "
query := "UPDATE channels SET "
for i, clause := range setClauses {
if i > 0 {
query += ", "
@@ -343,38 +386,38 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) {
query += clause
}
query += " WHERE id = $" + strconv.Itoa(argN) + " AND user_id = $" + strconv.Itoa(argN+1)
args = append(args, chatID, userID)
args = append(args, channelID, userID)
_, err = database.DB.Exec(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update chat"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
return
}
// Return updated chat
h.GetChat(c)
// Return updated channel
h.GetChannel(c)
}
// ── Delete Chat ─────────────────────────────
// ── Delete Channel ──────────────────────────
func (h *ChatHandler) DeleteChat(c *gin.Context) {
func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
userID := getUserID(c)
chatID := c.Param("id")
channelID := c.Param("id")
result, err := database.DB.Exec(
`DELETE FROM chats WHERE id = $1 AND user_id = $2`,
chatID, userID,
`DELETE FROM channels WHERE id = $1 AND user_id = $2`,
channelID, userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete chat"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"})
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "chat deleted"})
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
}

View File

@@ -13,56 +13,56 @@ func init() {
gin.SetMode(gin.TestMode)
}
// ── Chat Request Validation ─────────────────
// ── Channel Request Validation ─────────────────
func TestCreateChatMissingTitle(t *testing.T) {
h := NewChatHandler()
func TestCreateChannelMissingTitle(t *testing.T) {
h := NewChannelHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/chats",
c.Request = httptest.NewRequest("POST", "/api/v1/channels",
strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateChat(c)
h.CreateChannel(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateChatTitleTooLong(t *testing.T) {
h := NewChatHandler()
func TestCreateChannelTitleTooLong(t *testing.T) {
h := NewChannelHandler()
longTitle := strings.Repeat("x", 501)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/chats",
c.Request = httptest.NewRequest("POST", "/api/v1/channels",
strings.NewReader(`{"title":"`+longTitle+`"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateChat(c)
h.CreateChannel(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for title > 500 chars, got %d", w.Code)
}
}
func TestUpdateChatEmptyBody(t *testing.T) {
h := NewChatHandler()
func TestUpdateChannelEmptyBody(t *testing.T) {
h := NewChannelHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "test-user-id")
c.Params = gin.Params{{Key: "id", Value: "test-chat-id"}}
c.Request = httptest.NewRequest("PUT", "/api/v1/chats/test-chat-id",
c.Params = gin.Params{{Key: "id", Value: "test-channel-id"}}
c.Request = httptest.NewRequest("PUT", "/api/v1/channels/test-channel-id",
strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
// Without a DB connection, UpdateChat will fail at ownership check.
// Without a DB connection, UpdateChannel will fail at ownership check.
// Integration tests with a real DB would validate the "no fields" path.
// Here we just confirm it doesn't return 400 for valid JSON.
h.UpdateChat(c)
h.UpdateChannel(c)
if w.Code == http.StatusBadRequest {
t.Error("Empty JSON body should not be a parse error")
@@ -76,8 +76,8 @@ func TestCreateMessageMissingRole(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "test-chat"}}
c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/messages",
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
strings.NewReader(`{"content":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
@@ -93,8 +93,8 @@ func TestCreateMessageInvalidRole(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "test-chat"}}
c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/messages",
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
strings.NewReader(`{"role":"invalid","content":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
@@ -110,8 +110,8 @@ func TestCreateMessageMissingContent(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "test-chat"}}
c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/messages",
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
strings.NewReader(`{"role":"user"}`))
c.Request.Header.Set("Content-Type", "application/json")
@@ -135,7 +135,7 @@ func TestRegenerateReturns501(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/chats/x/regenerate", nil)
c.Request = httptest.NewRequest("POST", "/api/v1/channels/x/regenerate", nil)
h.Regenerate(c)
@@ -149,7 +149,7 @@ func TestRegenerateReturns501(t *testing.T) {
func TestParsePaginationDefaults(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/chats", nil)
c.Request = httptest.NewRequest("GET", "/api/v1/channels", nil)
page, perPage, offset := parsePagination(c)
@@ -167,7 +167,7 @@ func TestParsePaginationDefaults(t *testing.T) {
func TestParsePaginationCustom(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/chats?page=3&per_page=10", nil)
c.Request = httptest.NewRequest("GET", "/api/v1/channels?page=3&per_page=10", nil)
page, perPage, offset := parsePagination(c)
@@ -185,7 +185,7 @@ func TestParsePaginationCustom(t *testing.T) {
func TestParsePaginationClampMax(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/chats?per_page=500", nil)
c.Request = httptest.NewRequest("GET", "/api/v1/channels?per_page=500", nil)
_, perPage, _ := parsePagination(c)

View File

@@ -17,7 +17,8 @@ import (
// ── Request Types ───────────────────────────
type completionRequest struct {
ChatID string `json:"chat_id" binding:"required"`
ChannelID string `json:"channel_id"` // preferred; validated manually below
ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
Content string `json:"content" binding:"required"`
Model string `json:"model,omitempty"`
APIConfigID string `json:"api_config_id,omitempty"`
@@ -41,7 +42,7 @@ func NewCompletionHandler() *CompletionHandler {
// Flow:
// 1. Validate request, verify chat ownership
// 2. Resolve api_config (from request, chat, or user default)
// 3. Load conversation history from chat_messages
// 3. Load conversation history from messages
// 4. Persist user message
// 5. Call provider (stream or non-stream)
// 6. Stream SSE to client / return JSON
@@ -54,15 +55,25 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
return
}
// Support chat_id as alias during frontend transition
channelID := req.ChannelID
if channelID == "" {
channelID = req.ChatID
}
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id is required"})
return
}
userID := getUserID(c)
// Verify chat ownership
if !userOwnsChat(c, req.ChatID, userID) {
// Verify channel ownership
if !userOwnsChannel(c, channelID, userID) {
return
}
// Resolve provider config
providerCfg, providerID, model, configID, err := h.resolveConfig(userID, req)
providerCfg, providerID, model, configID, err := h.resolveConfig(userID, channelID, req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -75,7 +86,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// Load conversation history
messages, err := h.loadConversation(req.ChatID)
messages, err := h.loadConversation(channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
return
@@ -88,7 +99,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
})
// Persist user message
if err := h.persistMessage(req.ChatID, "user", req.Content, "", 0, 0); err != nil {
if err := h.persistMessage(channelID, "user", req.Content, "", 0, 0); err != nil {
log.Printf("Failed to persist user message: %v", err)
}
@@ -122,9 +133,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
if stream {
h.streamCompletion(c, provider, providerCfg, provReq, req.ChatID, model)
h.streamCompletion(c, provider, providerCfg, provReq, channelID, model)
} else {
h.syncCompletion(c, provider, providerCfg, provReq, req.ChatID, model)
h.syncCompletion(c, provider, providerCfg, provReq, channelID, model)
}
}
@@ -135,7 +146,7 @@ func (h *CompletionHandler) streamCompletion(
provider providers.Provider,
cfg providers.ProviderConfig,
req providers.CompletionRequest,
chatID, model string,
channelID, model string,
) {
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
if err != nil {
@@ -190,7 +201,7 @@ func (h *CompletionHandler) streamCompletion(
// Persist assistant response
if fullContent != "" {
if err := h.persistMessage(chatID, "assistant", fullContent, model, 0, 0); err != nil {
if err := h.persistMessage(channelID, "assistant", fullContent, model, 0, 0); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
}
@@ -203,7 +214,7 @@ func (h *CompletionHandler) syncCompletion(
provider providers.Provider,
cfg providers.ProviderConfig,
req providers.CompletionRequest,
chatID, model string,
channelID, model string,
) {
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
if err != nil {
@@ -212,7 +223,7 @@ func (h *CompletionHandler) syncCompletion(
}
// Persist assistant response
if err := h.persistMessage(chatID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens); err != nil {
if err := h.persistMessage(channelID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
@@ -271,7 +282,7 @@ func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) prov
// ── Config Resolution ───────────────────────
// Priority: request.api_config_id → chat.api_config_id → user's first active config
func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) (providers.ProviderConfig, string, string, string, error) {
func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, error) {
var configID string
// 1. Explicit config from request
@@ -279,14 +290,14 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
configID = req.APIConfigID
}
// 2. Config from chat
// 2. Config from channel
if configID == "" {
var chatConfigID *string
var channelConfigID *string
err := database.DB.QueryRow(
`SELECT api_config_id FROM chats WHERE id = $1`, req.ChatID,
).Scan(&chatConfigID)
if err == nil && chatConfigID != nil {
configID = *chatConfigID
`SELECT api_config_id FROM channels WHERE id = $1`, channelID,
).Scan(&channelConfigID)
if err == nil && channelConfigID != nil {
configID = *channelConfigID
}
}
@@ -294,7 +305,7 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
if configID == "" {
err := database.DB.QueryRow(`
SELECT id FROM api_configs
WHERE (user_id = $1 OR is_global = true) AND is_active = true
WHERE (user_id = $1 OR is_global = true OR user_id IS NULL) AND is_active = true
ORDER BY user_id NULLS LAST, created_at ASC
LIMIT 1
`, userID).Scan(&configID)
@@ -310,7 +321,7 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
err := database.DB.QueryRow(`
SELECT provider, endpoint, api_key_encrypted, model_default, custom_headers, provider_settings
FROM api_configs
WHERE id = $1 AND (user_id = $2 OR is_global = true) AND is_active = true
WHERE id = $1 AND (user_id = $2 OR is_global = true OR user_id IS NULL) AND is_active = true
`, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON)
if err == sql.ErrNoRows {
@@ -356,11 +367,11 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
// ── Conversation Loader ─────────────────────
func (h *CompletionHandler) loadConversation(chatID string) ([]providers.Message, error) {
// Load system prompt from chat
func (h *CompletionHandler) loadConversation(channelID string) ([]providers.Message, error) {
// Load system prompt from channel
var systemPrompt *string
_ = database.DB.QueryRow(
`SELECT system_prompt FROM chats WHERE id = $1`, chatID,
`SELECT system_prompt FROM channels WHERE id = $1`, channelID,
).Scan(&systemPrompt)
messages := make([]providers.Message, 0)
@@ -374,10 +385,10 @@ func (h *CompletionHandler) loadConversation(chatID string) ([]providers.Message
// Load message history (oldest first)
rows, err := database.DB.Query(`
SELECT role, content FROM chat_messages
WHERE chat_id = $1
SELECT role, content FROM messages
WHERE channel_id = $1
ORDER BY created_at ASC
`, chatID)
`, channelID)
if err != nil {
return nil, err
}
@@ -396,22 +407,42 @@ func (h *CompletionHandler) loadConversation(chatID string) ([]providers.Message
// ── Message Persistence ─────────────────────
func (h *CompletionHandler) persistMessage(chatID, role, content, model string, inputTokens, outputTokens int) error {
func (h *CompletionHandler) persistMessage(channelID, role, content, model string, inputTokens, outputTokens int) error {
var tokensUsed *int
if inputTokens > 0 || outputTokens > 0 {
total := inputTokens + outputTokens
tokensUsed = &total
}
// Find current leaf for parent_id
var parentID *string
_ = database.DB.QueryRow(
`SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1`,
channelID,
).Scan(&parentID)
participantType := "user"
participantID := channelID // will be overridden below
if role == "assistant" {
participantType = "model"
participantID = model
} else {
// Get channel owner as participant_id for user messages
var ownerID string
if err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID); err == nil {
participantID = ownerID
}
}
_, err := database.DB.Exec(`
INSERT INTO chat_messages (chat_id, role, content, model, tokens_used)
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
`, chatID, role, content, model, tokensUsed)
INSERT INTO messages (channel_id, role, content, model, tokens_used, parent_id, participant_type, participant_id)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8)
`, channelID, role, content, model, tokensUsed, parentID, participantType, participantID)
if err != nil {
return err
}
// Touch chat updated_at
_, _ = database.DB.Exec(`UPDATE chats SET updated_at = NOW() WHERE id = $1`, chatID)
// Touch channel updated_at
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
return nil
}

View File

@@ -19,13 +19,14 @@ type createMessageRequest struct {
}
type messageResponse struct {
ID string `json:"id"`
ChatID string `json:"chat_id"`
Role string `json:"role"`
Content string `json:"content"`
Model *string `json:"model"`
TokensUsed *int `json:"tokens_used"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
ChannelID string `json:"channel_id"`
Role string `json:"role"`
Content string `json:"content"`
Model *string `json:"model"`
TokensUsed *int `json:"tokens_used"`
ParentID *string `json:"parent_id,omitempty"`
CreatedAt string `json:"created_at"`
}
// MessageHandler holds dependencies for message endpoints.
@@ -42,18 +43,18 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
page, perPage, offset := parsePagination(c)
userID := getUserID(c)
chatID := c.Param("id")
channelID := c.Param("id")
// Verify ownership
if !userOwnsChat(c, chatID, userID) {
if !userOwnsChannel(c, channelID, userID) {
return
}
// Count total messages
var total int
err := database.DB.QueryRow(
`SELECT COUNT(*) FROM chat_messages WHERE chat_id = $1`,
chatID,
`SELECT COUNT(*) FROM messages WHERE channel_id = $1`,
channelID,
).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count messages"})
@@ -62,12 +63,12 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
// Fetch messages (oldest first for conversation display)
rows, err := database.DB.Query(`
SELECT id, chat_id, role, content, model, tokens_used, created_at
FROM chat_messages
WHERE chat_id = $1
SELECT id, channel_id, role, content, model, tokens_used, parent_id, created_at
FROM messages
WHERE channel_id = $1
ORDER BY created_at ASC
LIMIT $2 OFFSET $3
`, chatID, perPage, offset)
`, channelID, perPage, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"})
return
@@ -78,8 +79,8 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
for rows.Next() {
var msg messageResponse
err := rows.Scan(
&msg.ID, &msg.ChatID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.CreatedAt,
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID, &msg.CreatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"})
@@ -107,29 +108,49 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
}
userID := getUserID(c)
chatID := c.Param("id")
channelID := c.Param("id")
// Verify ownership
if !userOwnsChat(c, chatID, userID) {
if !userOwnsChannel(c, channelID, userID) {
return
}
// Find the current leaf message to set as parent
var parentID *string
_ = database.DB.QueryRow(
`SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1`,
channelID,
).Scan(&parentID)
var msg messageResponse
err := database.DB.QueryRow(`
INSERT INTO chat_messages (chat_id, role, content, model)
VALUES ($1, $2, $3, $4)
RETURNING id, chat_id, role, content, model, tokens_used, created_at
`, chatID, req.Role, req.Content, req.Model).Scan(
&msg.ID, &msg.ChatID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.CreatedAt,
INSERT INTO messages (channel_id, role, content, model, parent_id, participant_type, participant_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, channel_id, role, content, model, tokens_used, parent_id, created_at
`, channelID, req.Role, req.Content, req.Model, parentID,
func() string {
if req.Role == "assistant" {
return "model"
}
return "user"
}(),
func() string {
if req.Role == "assistant" && req.Model != "" {
return req.Model
}
return userID
}(),
).Scan(
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID, &msg.CreatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
return
}
// Touch the chat's updated_at so it sorts to top of list
_, _ = database.DB.Exec(`UPDATE chats SET updated_at = NOW() WHERE id = $1`, chatID)
// Touch the channel's updated_at so it sorts to top of list
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
c.JSON(http.StatusCreated, msg)
}
@@ -152,22 +173,22 @@ func (h *MessageHandler) Stream(c *gin.Context) {
// ── Ownership Check ─────────────────────────
func userOwnsChat(c *gin.Context, chatID, userID string) bool {
func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
var ownerID string
err := database.DB.QueryRow(
`SELECT user_id FROM chats WHERE id = $1`, chatID,
`SELECT user_id FROM channels WHERE id = $1`, channelID,
).Scan(&ownerID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"})
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return false
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify chat ownership"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel ownership"})
return false
}
if ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your chat"})
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false
}
return true

View File

@@ -27,18 +27,24 @@ func main() {
if err := database.Migrate(); err != nil {
log.Fatalf("❌ Schema migration failed: %v", err)
}
// Bootstrap admin from env (K8s secret) — upserts on every restart
handlers.BootstrapAdmin(cfg)
}
defer database.Close()
r := gin.Default()
r.Use(middleware.CORS())
// ── Base path group ──────────────────────
// All routes live under cfg.BasePath (e.g. "/dev", "/test", or "")
base := r.Group(cfg.BasePath)
// ── EventBus + WebSocket Hub ─────────────
bus := events.NewBus()
hub := events.NewHub(bus)
// Health check (k8s probes hit this directly)
r.GET("/health", func(c *gin.Context) {
base.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"version": Version,
@@ -48,13 +54,13 @@ func main() {
})
// WebSocket endpoint — auth via ?token= query param
r.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
// ── Auth routes (rate limited) ──────────────
auth := handlers.NewAuthHandler(cfg)
authLimiter := middleware.NewRateLimiter(1, 5)
api := r.Group("/api/v1")
api := base.Group("/api/v1")
{
// Health (routable through ingress)
api.GET("/health", func(c *gin.Context) {
@@ -85,23 +91,23 @@ func main() {
protected := api.Group("")
protected.Use(middleware.Auth(cfg))
{
// Chats
chats := handlers.NewChatHandler()
protected.GET("/chats", chats.ListChats)
protected.POST("/chats", chats.CreateChat)
protected.GET("/chats/:id", chats.GetChat)
protected.PUT("/chats/:id", chats.UpdateChat)
protected.DELETE("/chats/:id", chats.DeleteChat)
// Channels (unified: replaces /chats)
channels := handlers.NewChannelHandler()
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
protected.GET("/channels/:id", channels.GetChannel)
protected.PUT("/channels/:id", channels.UpdateChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Messages
msgs := handlers.NewMessageHandler()
protected.GET("/chats/:id/messages", msgs.ListMessages)
protected.POST("/chats/:id/messages", msgs.CreateMessage)
protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage)
// ── Chat Engine (#8) ────────────────
comp := handlers.NewCompletionHandler()
protected.POST("/chat/completions", comp.Complete)
protected.POST("/chats/:id/regenerate", msgs.Regenerate) // TODO: wire to completion handler
protected.POST("/channels/:id/regenerate", msgs.Regenerate) // TODO: wire to completion handler
// API Configs
apiCfg := handlers.NewAPIConfigHandler()
@@ -123,6 +129,10 @@ func main() {
protected.POST("/profile/password", settings.ChangePassword)
protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings)
// Public global settings (non-admin users can read safe subset)
adm := handlers.NewAdminHandler()
protected.GET("/settings/public", adm.PublicSettings)
}
// ── Admin routes ────────────────────────
@@ -156,15 +166,21 @@ func main() {
// Model Configs
admin.GET("/models", adm.ListModelConfigs)
admin.POST("/models/fetch", adm.FetchModels)
admin.PUT("/models/bulk", adm.BulkUpdateModels)
admin.PUT("/models/:id", adm.UpdateModelConfig)
admin.DELETE("/models/:id", adm.DeleteModelConfig)
}
}
bp := cfg.BasePath
if bp == "" {
bp = "/"
}
log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
log.Printf(" Base path: %s", bp)
log.Printf(" Schema: %s", database.SchemaVersion())
log.Printf(" Providers: %v", providers.List())
log.Printf(" EventBus: ready, WebSocket on /ws")
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}

View File

@@ -53,7 +53,7 @@ func TestCORSHeaders(t *testing.T) {
func TestAllRoutesRegistered(t *testing.T) {
cfg := &config.Config{JWTSecret: "test"}
auth := handlers.NewAuthHandler(cfg)
chats := handlers.NewChatHandler()
channels := handlers.NewChannelHandler()
msgs := handlers.NewMessageHandler()
comp := handlers.NewCompletionHandler()
apiCfg := handlers.NewAPIConfigHandler()
@@ -73,19 +73,19 @@ func TestAllRoutesRegistered(t *testing.T) {
protected := api.Group("")
{
// Chats
protected.GET("/chats", chats.ListChats)
protected.POST("/chats", chats.CreateChat)
protected.GET("/chats/:id", chats.GetChat)
protected.PUT("/chats/:id", chats.UpdateChat)
protected.DELETE("/chats/:id", chats.DeleteChat)
// Channels
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
protected.GET("/channels/:id", channels.GetChannel)
protected.PUT("/channels/:id", channels.UpdateChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Messages
protected.GET("/chats/:id/messages", msgs.ListMessages)
protected.POST("/chats/:id/messages", msgs.CreateMessage)
protected.POST("/chats/:id/regenerate", msgs.Regenerate)
protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage)
protected.POST("/channels/:id/regenerate", msgs.Regenerate)
// Chat Engine
// Completion Engine
protected.POST("/chat/completions", comp.Complete)
// API Configs
@@ -140,17 +140,17 @@ func TestAllRoutesRegistered(t *testing.T) {
"POST /api/v1/auth/login",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
// Chats
"GET /api/v1/chats",
"POST /api/v1/chats",
"GET /api/v1/chats/:id",
"PUT /api/v1/chats/:id",
"DELETE /api/v1/chats/:id",
// Channels
"GET /api/v1/channels",
"POST /api/v1/channels",
"GET /api/v1/channels/:id",
"PUT /api/v1/channels/:id",
"DELETE /api/v1/channels/:id",
// Messages
"GET /api/v1/chats/:id/messages",
"POST /api/v1/chats/:id/messages",
"POST /api/v1/chats/:id/regenerate",
// Chat Engine
"GET /api/v1/channels/:id/messages",
"POST /api/v1/channels/:id/messages",
"POST /api/v1/channels/:id/regenerate",
// Completion Engine
"POST /api/v1/chat/completions",
// API Configs
"GET /api/v1/api-configs",

View File

@@ -14,12 +14,12 @@ type BaseModel struct {
// User represents a user in the system
type User struct {
BaseModel
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"`
Name string `json:"name" db:"name"`
Role string `json:"role" db:"role"`
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"`
Name string `json:"name" db:"name"`
Role string `json:"role" db:"role"`
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
IsActive bool `json:"is_active" db:"is_active"`
IsActive bool `json:"is_active" db:"is_active"`
}
// UserRole constants
@@ -28,121 +28,147 @@ const (
UserRoleAdmin = "admin"
)
// Chat represents a chat conversation
type Chat struct {
// ── Channel Types ───────────────────────────
const (
ChannelTypeDirect = "direct" // 1:1 AI chat (legacy "chat")
ChannelTypeGroup = "group" // multi-model conversation
ChannelTypeChannel = "channel" // named, persistent, membered
)
// Channel represents a conversation channel (unified: chats + channels).
type Channel struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"`
Model string `json:"model" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
IsArchived bool `json:"is_archived" db:"is_archived"`
UserID string `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"`
Description string `json:"description,omitempty" db:"description"`
Type string `json:"type" db:"type"`
Model string `json:"model,omitempty" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
APIConfigID *string `json:"api_config_id,omitempty" db:"api_config_id"`
IsArchived bool `json:"is_archived" db:"is_archived"`
IsPinned bool `json:"is_pinned" db:"is_pinned"`
FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
}
// Message represents a chat message
// Message represents a message in a channel
type Message struct {
BaseModel
ChatID string `json:"chat_id" db:"chat_id"`
Role string `json:"role" db:"role"` // "user", "assistant", "system"
Content string `json:"content" db:"content"`
Tokens int `json:"tokens,omitempty" db:"tokens"`
Model string `json:"model,omitempty" db:"model"`
FinishReason string `json:"finish_reason,omitempty" db:"finish_reason"`
ChannelID string `json:"channel_id" db:"channel_id"`
Role string `json:"role" db:"role"`
Content string `json:"content" db:"content"`
Tokens int `json:"tokens,omitempty" db:"tokens"`
Model string `json:"model,omitempty" db:"model"`
FinishReason string `json:"finish_reason,omitempty" db:"finish_reason"`
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
}
// MessageRole constants
const (
MessageRoleUser = "user"
MessageRoleAssistant = "assistant"
MessageRoleSystem = "system"
)
// APIConfig represents a configured API provider
type APIConfig struct {
// ── Channel Members & Models ────────────────
type ChannelMember struct {
ID string `json:"id" db:"id"`
ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"`
Role string `json:"role" db:"role"`
JoinedAt time.Time `json:"joined_at" db:"joined_at"`
LastReadAt time.Time `json:"last_read_at" db:"last_read_at"`
}
type ChannelModel struct {
ID string `json:"id" db:"id"`
ChannelID string `json:"channel_id" db:"channel_id"`
ModelID string `json:"model_id" db:"model_id"`
APIConfigID string `json:"api_config_id,omitempty" db:"api_config_id"`
DisplayName string `json:"display_name,omitempty" db:"display_name"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
IsDefault bool `json:"is_default" db:"is_default"`
}
type ChannelCursor struct {
ID string `json:"id" db:"id"`
ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"`
ActiveLeafID *string `json:"active_leaf_id,omitempty" db:"active_leaf_id"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ── Organization ────────────────────────────
type Folder struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
}
type Project struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Provider string `json:"provider" db:"provider"` // "openai", "anthropic", "google", etc.
APIKey string `json:"-" db:"api_key"`
BaseURL string `json:"base_url,omitempty" db:"base_url"`
Model string `json:"model" db:"model"`
IsDefault bool `json:"is_default" db:"is_default"`
}
// Channel represents a chat channel
type Channel struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description,omitempty" db:"description"`
IsPrivate bool `json:"is_private" db:"is_private"`
OwnerID string `json:"owner_id" db:"owner_id"`
Color string `json:"color,omitempty" db:"color"`
}
// ChannelMember represents a member of a channel
type ChannelMember struct {
// ── API Config ──────────────────────────────
type APIConfig struct {
BaseModel
ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"`
Role string `json:"role" db:"role"` // "member", "moderator", "admin"
Name string `json:"name" db:"name"`
Provider string `json:"provider" db:"provider"`
APIKey string `json:"-" db:"api_key"`
BaseURL string `json:"base_url,omitempty" db:"base_url"`
Model string `json:"model" db:"model"`
IsDefault bool `json:"is_default" db:"is_default"`
}
// ChannelMessage represents a message in a channel
type ChannelMessage struct {
BaseModel
ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"`
Content string `json:"content" db:"content"`
ParentID string `json:"parent_id,omitempty" db:"parent_id"`
}
// ── Notes (future Phase 2) ──────────────────
// Note represents a user note
type Note struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
Tags []string `json:"tags,omitempty" db:"tags"`
FolderID string `json:"folder_id,omitempty" db:"folder_id"`
IsShared bool `json:"is_shared" db:"is_shared"`
UserID string `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
Tags []string `json:"tags,omitempty" db:"tags"`
FolderID string `json:"folder_id,omitempty" db:"folder_id"`
IsShared bool `json:"is_shared" db:"is_shared"`
}
// KnowledgeBase represents a knowledge base collection
type KnowledgeBase struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Source string `json:"source" db:"source"` // "url", "file", "text"
Settings string `json:"settings,omitempty" db:"settings"` // JSON settings
}
// Workflow represents a workflow definition
type Workflow struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Steps string `json:"steps" db:"steps"` // JSON array of steps
Source string `json:"source" db:"source"`
Settings string `json:"settings,omitempty" db:"settings"`
IsPublic bool `json:"is_public" db:"is_public"`
}
// Settings represents user settings
// ── Settings ────────────────────────────────
type Settings struct {
UserID string `json:"user_id" db:"user_id"`
Theme string `json:"theme" db:"theme"`
Language string `json:"language" db:"language"`
Model string `json:"model" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
MaxTokens int `json:"max_tokens" db:"max_tokens"`
Temperature float64 `json:"temperature" db:"temperature"`
DefaultAPIConfigID string `json:"default_api_config_id,omitempty" db:"default_api_config_id"`
UserID string `json:"user_id" db:"user_id"`
Theme string `json:"theme" db:"theme"`
Language string `json:"language" db:"language"`
Model string `json:"model" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
MaxTokens int `json:"max_tokens" db:"max_tokens"`
Temperature float64 `json:"temperature" db:"temperature"`
DefaultAPIConfigID string `json:"default_api_config_id,omitempty" db:"default_api_config_id"`
}
// APIToken represents an API token for external access
type APIToken struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Token string `json:"-" db:"token"`
ExpiresAt time.Time `json:"expires_at,omitempty" db:"expires_at"`
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Token string `json:"-" db:"token"`
ExpiresAt time.Time `json:"expires_at,omitempty" db:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"`
IsActive bool `json:"is_active" db:"is_active"`
}
IsActive bool `json:"is_active" db:"is_active"`
}