Changeset 0.17.1 (#76)

This commit is contained in:
2026-02-28 01:40:31 +00:00
parent c9141a6896
commit 856dc9b0ac
64 changed files with 8037 additions and 1657 deletions

View File

@@ -12,6 +12,7 @@ import (
"github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Request / Response types ────────────────
@@ -85,6 +86,73 @@ func SetChannelDeleteHook(fn func(channelID string)) {
channelDeleteHook = fn
}
// ── Tag Helpers ─────────────────────────────
// writeTagsArg returns a value suitable for inserting/updating the tags column.
// Postgres: pq.Array SQLite: JSON string
func writeTagsArg(tags []string) interface{} {
if database.IsSQLite() {
b, _ := json.Marshal(tags)
return string(b)
}
return pq.Array(tags)
}
// ── JSON scanner (settings column) ──────────
// SQLite returns TEXT for JSON columns; json.RawMessage ([]byte) can't scan
// from a string with modernc.org/sqlite. This wrapper handles both dialects.
type jsonScanner struct{ dest *json.RawMessage }
func scanJSON(dest *json.RawMessage) *jsonScanner { return &jsonScanner{dest: dest} }
func (s *jsonScanner) Scan(src interface{}) error {
switch v := src.(type) {
case []byte:
*s.dest = json.RawMessage(v)
case string:
*s.dest = json.RawMessage(v)
case nil:
*s.dest = json.RawMessage("{}")
default:
*s.dest = json.RawMessage("{}")
}
return nil
}
// tagsScanner wraps a *[]string so rows.Scan can populate it on both dialects.
type tagsScanner struct {
dest *[]string
}
func scanTags(dest *[]string) *tagsScanner {
return &tagsScanner{dest: dest}
}
func (s *tagsScanner) Scan(src interface{}) error {
if database.IsSQLite() {
var raw string
switch v := src.(type) {
case string:
raw = v
case []byte:
raw = string(v)
case nil:
*s.dest = []string{}
return nil
}
var arr []string
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
*s.dest = []string{}
return nil
}
*s.dest = arr
return nil
}
// Postgres: delegate to pq
return pq.Array(s.dest).Scan(src)
}
// ── Helpers ─────────────────────────────────
// getUserID extracts the authenticated user's ID from context.
@@ -146,7 +214,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
}
var total int
if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
if err := database.DB.QueryRow(database.Q(countQuery), countArgs...).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"})
return
}
@@ -185,7 +253,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
args = append(args, perPage, offset)
rows, err := database.DB.Query(query, args...)
rows, err := database.DB.Query(database.Q(query), args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
return
@@ -199,7 +267,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
err := rows.Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), &ch.Settings,
scanTags(&tags), scanJSON(&ch.Settings),
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
@@ -243,23 +311,54 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
channelType = "direct"
}
// INSERT and retrieve the new row
var ch channelResponse
var tags []string
err := database.DB.QueryRow(`
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
is_archived, is_pinned, folder, tags, settings, created_at, updated_at
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
req.Folder, pq.Array(req.Tags),
).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
return
if database.IsSQLite() {
id := store.NewID()
_, err := database.DB.Exec(`
INSERT INTO channels (id, user_id, title, type, description, model,
system_prompt, provider_config_id, folder, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, userID, req.Title, channelType, req.Description, req.Model,
req.SystemPrompt, req.APIConfigID, req.Folder, writeTagsArg(req.Tags),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
return
}
// Read back the row
err = database.DB.QueryRow(`
SELECT id, user_id, title, type, description, model, provider_config_id,
system_prompt, is_archived, is_pinned, folder, tags, settings,
created_at, updated_at
FROM channels WHERE id = ?`, id).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created channel: " + err.Error()})
return
}
} else {
err := database.DB.QueryRow(`
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
is_archived, is_pinned, folder, tags, settings, created_at, updated_at
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
req.Folder, pq.Array(req.Tags),
).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
return
}
}
if tags == nil {
@@ -269,19 +368,35 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
ch.MessageCount = 0
// Auto-create channel_member for the creator
_, _ = database.DB.Exec(`
INSERT INTO channel_members (channel_id, user_id, role)
VALUES ($1, $2, 'owner')
ON CONFLICT DO NOTHING
`, ch.ID, userID)
if database.IsSQLite() {
_, _ = database.DB.Exec(`
INSERT INTO channel_members (id, channel_id, user_id, role)
VALUES (?, ?, ?, 'owner')
ON CONFLICT DO NOTHING
`, store.NewID(), ch.ID, userID)
} else {
_, _ = database.DB.Exec(`
INSERT INTO channel_members (channel_id, user_id, role)
VALUES ($1, $2, 'owner')
ON CONFLICT DO NOTHING
`, ch.ID, userID)
}
// Auto-create channel_model if model specified
if req.Model != "" {
_, _ = database.DB.Exec(`
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
VALUES ($1, $2, $3, true)
ON CONFLICT DO NOTHING
`, ch.ID, req.Model, req.APIConfigID)
if database.IsSQLite() {
_, _ = database.DB.Exec(`
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, is_default)
VALUES (?, ?, ?, ?, 1)
ON CONFLICT DO NOTHING
`, store.NewID(), ch.ID, req.Model, req.APIConfigID)
} else {
_, _ = database.DB.Exec(`
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
VALUES ($1, $2, $3, true)
ON CONFLICT DO NOTHING
`, ch.ID, req.Model, req.APIConfigID)
}
}
c.JSON(http.StatusCreated, ch)
@@ -295,7 +410,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
var ch channelResponse
var tags []string
err := database.DB.QueryRow(`
err := database.DB.QueryRow(database.Q(`
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count,
@@ -305,10 +420,10 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
) mc ON mc.channel_id = c.id
WHERE c.id = $1 AND c.user_id = $2
`, channelID, userID).Scan(
`), channelID, userID).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), &ch.Settings,
scanTags(&tags), scanJSON(&ch.Settings),
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
@@ -348,7 +463,7 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
// Verify ownership
var ownerID string
err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID)
err := database.DB.QueryRow(database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID).Scan(&ownerID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
@@ -358,15 +473,13 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
return
}
// Build dynamic UPDATE
// Build dynamic UPDATE with ? placeholders (converted for Postgres)
setClauses := []string{}
args := []interface{}{}
argN := 1
addClause := func(col string, val interface{}) {
setClauses = append(setClauses, col+" = $"+strconv.Itoa(argN))
setClauses = append(setClauses, col+" = ?")
args = append(args, val)
argN++
}
if req.Title != nil {
@@ -394,13 +507,16 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
addClause("folder", *req.Folder)
}
if req.Tags != nil {
addClause("tags", pq.Array(req.Tags))
addClause("tags", writeTagsArg(req.Tags))
}
if req.Settings != nil {
// JSONB merge: new settings keys overwrite existing, unmentioned keys preserved
setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
if database.IsSQLite() {
setClauses = append(setClauses, "settings = json_patch(settings, ?)")
} else {
setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || ?::jsonb")
}
args = append(args, []byte(*req.Settings))
argN++
}
if len(setClauses) == 0 {
@@ -408,16 +524,14 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
return
}
query := "UPDATE channels SET "
for i, clause := range setClauses {
if i > 0 {
query += ", "
}
query += clause
}
query += " WHERE id = $" + strconv.Itoa(argN) + " AND user_id = $" + strconv.Itoa(argN+1)
query := "UPDATE channels SET " + strings.Join(setClauses, ", ")
query += " WHERE id = ? AND user_id = ?"
args = append(args, channelID, userID)
if !database.IsSQLite() {
query = convertPlaceholders(query)
}
_, err = database.DB.Exec(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
@@ -435,7 +549,7 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
channelID := c.Param("id")
result, err := database.DB.Exec(
`DELETE FROM channels WHERE id = $1 AND user_id = $2`,
database.Q(`DELETE FROM channels WHERE id = $1 AND user_id = $2`),
channelID, userID,
)
if err != nil {