Changeset 0.6.0 (#36)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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", `{}`},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user