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

@@ -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