Changeset 0.29.0 (#195)

This commit is contained in:
2026-03-17 16:28:47 +00:00
parent 128cbb8174
commit 5d637d3a90
129 changed files with 9418 additions and 3016 deletions

View File

@@ -12,12 +12,13 @@ import (
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools"
"git.gobha.me/xcaliber/chat-switchboard/treepath"
)
// ── Request / Response types ────────────────
@@ -50,12 +51,12 @@ type editRequest struct {
}
type regenerateRequest struct {
Model string `json:"model,omitempty"`
PersonaID string `json:"persona_id,omitempty"`
ProviderConfigID string `json:"provider_config_id,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
DisabledTools []string `json:"disabled_tools,omitempty"`
Model string `json:"model,omitempty"`
PersonaID string `json:"persona_id,omitempty"`
ProviderConfigID string `json:"provider_config_id,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
DisabledTools []string `json:"disabled_tools,omitempty"`
}
type cursorRequest struct {
@@ -77,9 +78,6 @@ func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *even
// ── List Messages (flat, all branches) ──────
// GET /channels/:id/messages
//
// Returns all live messages across all branches (useful for admin/debug).
// Frontend should prefer /channels/:id/path for rendering.
func (h *MessageHandler) ListMessages(c *gin.Context) {
page, perPage, offset := parsePagination(c)
@@ -93,62 +91,37 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
} else if !userOwnsChannel(c, channelID, userID) {
} else if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
var total int
err := database.DB.QueryRow(
database.Q(`SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`),
channelID,
).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count messages"})
return
}
rows, err := database.DB.Query(database.Q(`
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id,
m.sibling_index, m.participant_type, m.participant_id,
CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username)
WHEN m.participant_type = 'persona' THEN p.name
ELSE NULL END AS sender_name,
CASE WHEN m.participant_type = 'user' THEN u.avatar_url
WHEN m.participant_type = 'persona' THEN p.avatar
ELSE NULL END AS sender_avatar,
m.created_at
FROM messages m
LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text
LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text
WHERE m.channel_id = $1 AND m.deleted_at IS NULL
ORDER BY m.created_at ASC
LIMIT $2 OFFSET $3
`), channelID, perPage, offset)
ctx := c.Request.Context()
msgs, total, err := h.stores.Messages.ListWithSenderInfo(ctx, channelID, perPage, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"})
return
}
defer rows.Close()
messages := make([]messageResponse, 0)
for rows.Next() {
var msg messageResponse
err := rows.Scan(
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID,
&msg.SiblingIndex, &msg.ParticipantType, &msg.ParticipantID,
&msg.SenderName, &msg.SenderAvatar,
&msg.CreatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"})
return
}
messages = append(messages, msg)
messages := make([]messageResponse, 0, len(msgs))
for _, m := range msgs {
messages = append(messages, messageResponse{
ID: m.ID,
ChannelID: m.ChannelID,
Role: m.Role,
Content: m.Content,
Model: m.Model,
TokensUsed: m.TokensUsed,
ParentID: m.ParentID,
SiblingIndex: m.SiblingIndex,
ParticipantType: m.ParticipantType,
ParticipantID: m.ParticipantID,
SenderName: m.SenderName,
SenderAvatar: m.SenderAvatar,
CreatedAt: m.CreatedAt,
})
}
rows.Close() // release connection before sibling queries
// Enrich with sibling counts (requires DB access, must happen after rows are closed)
// Enrich with sibling counts
for i := range messages {
messages[i].SiblingCount = getSiblingCount(channelID, messages[i].ParentID)
}
@@ -164,15 +137,12 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
// ── Active Path ─────────────────────────────
// GET /channels/:id/path
//
// Returns the active branch from root → leaf with sibling metadata
// at each node. This is what the frontend renders.
func (h *MessageHandler) GetActivePath(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if !userOwnsChannel(c, channelID, userID) {
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
@@ -205,12 +175,11 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
} else if !userOwnsChannel(c, channelID, userID) {
} else if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Use cursor for parent, compute sibling_index
// Sessions use the same cursor logic (empty userID = channel-level latest)
effectiveUserID := userID
if isSessionAuth(c) {
effectiveUserID = c.GetString("session_id")
@@ -231,63 +200,43 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
}
}
var msg messageResponse
if database.IsSQLite() {
newID := store.NewID()
_, err := database.DB.Exec(`
INSERT INTO messages (id, channel_id, role, content, model, parent_id,
participant_type, participant_id, sibling_index)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, newID, channelID, req.Role, req.Content, req.Model, parentID,
participantType, participantID, siblingIdx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
return
}
err = database.DB.QueryRow(`
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
sibling_index, created_at
FROM messages WHERE id = ?`, newID).Scan(
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID,
&msg.SiblingIndex, &msg.CreatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created message"})
return
}
} else {
err := database.DB.QueryRow(`
INSERT INTO messages (channel_id, role, content, model, parent_id,
participant_type, participant_id, sibling_index)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, channel_id, role, content, model, tokens_used, parent_id,
sibling_index, created_at
`, channelID, req.Role, req.Content, req.Model, parentID,
participantType, participantID, siblingIdx,
).Scan(
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID,
&msg.SiblingIndex, &msg.CreatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
return
}
msg := &models.Message{
ChannelID: channelID,
Role: req.Role,
Content: req.Content,
Model: req.Model,
ParentID: parentID,
SiblingIndex: siblingIdx,
ParticipantType: participantType,
ParticipantID: participantID,
ToolCalls: models.JSONMap{},
Metadata: models.JSONMap{},
}
msg.SiblingCount = getSiblingCount(channelID, msg.ParentID)
_ = updateCursor(channelID, userID, msg.ID)
_, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID)
if err := h.stores.Messages.CreateWithCursor(c.Request.Context(), msg, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
return
}
c.JSON(http.StatusCreated, msg)
resp := messageResponse{
ID: msg.ID,
ChannelID: msg.ChannelID,
Role: msg.Role,
Content: msg.Content,
SiblingIndex: msg.SiblingIndex,
CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if msg.Model != "" {
resp.Model = &msg.Model
}
resp.ParentID = msg.ParentID
resp.SiblingCount = getSiblingCount(channelID, msg.ParentID)
c.JSON(http.StatusCreated, resp)
}
// ── Edit Message (create sibling) ───────────
// POST /channels/:id/messages/:msgId/edit
//
// Creates a new user message as a sibling of the target (same parent_id).
// Does NOT auto-trigger completion — the frontend sends a separate request.
func (h *MessageHandler) EditMessage(c *gin.Context) {
var req editRequest
@@ -300,18 +249,14 @@ func (h *MessageHandler) EditMessage(c *gin.Context) {
channelID := c.Param("id")
messageID := c.Param("msgId")
if !userOwnsChannel(c, channelID, userID) {
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Load target — must exist, belong to channel, be a user message
var targetParentID *string
var targetRole string
err := database.DB.QueryRow(database.Q(`
SELECT parent_id, role FROM messages
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
`), messageID, channelID).Scan(&targetParentID, &targetRole)
ctx := c.Request.Context()
// Load target — must exist, belong to channel, be a user message
targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
@@ -328,65 +273,39 @@ func (h *MessageHandler) EditMessage(c *gin.Context) {
// Create sibling: same parent_id as the target
siblingIdx := nextSiblingIndex(channelID, targetParentID)
var msg messageResponse
if database.IsSQLite() {
newID := store.NewID()
_, err = database.DB.Exec(`
INSERT INTO messages (id, channel_id, role, content, parent_id,
participant_type, participant_id, sibling_index)
VALUES (?, ?, 'user', ?, ?, 'user', ?, ?)
`, newID, channelID, req.Content, targetParentID, userID, siblingIdx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"})
return
}
err = database.DB.QueryRow(`
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
sibling_index, created_at
FROM messages WHERE id = ?`, newID).Scan(
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID,
&msg.SiblingIndex, &msg.CreatedAt,
)
} else {
err = database.DB.QueryRow(`
INSERT INTO messages (channel_id, role, content, parent_id,
participant_type, participant_id, sibling_index)
VALUES ($1, 'user', $2, $3, 'user', $4, $5)
RETURNING id, channel_id, role, content, model, tokens_used, parent_id,
sibling_index, created_at
`, channelID, req.Content, targetParentID, userID, siblingIdx,
).Scan(
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID,
&msg.SiblingIndex, &msg.CreatedAt,
)
msg := &models.Message{
ChannelID: channelID,
Role: "user",
Content: req.Content,
ParentID: targetParentID,
SiblingIndex: siblingIdx,
ParticipantType: "user",
ParticipantID: userID,
ToolCalls: models.JSONMap{},
Metadata: models.JSONMap{},
}
if err != nil {
if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"})
return
}
msg.SiblingCount = getSiblingCount(channelID, msg.ParentID)
// Cursor now points to the new sibling (it's a leaf — no children yet)
_ = updateCursor(channelID, userID, msg.ID)
_, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID)
resp := messageResponse{
ID: msg.ID,
ChannelID: msg.ChannelID,
Role: msg.Role,
Content: msg.Content,
ParentID: msg.ParentID,
SiblingIndex: msg.SiblingIndex,
SiblingCount: getSiblingCount(channelID, msg.ParentID),
CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
c.JSON(http.StatusCreated, msg)
c.JSON(http.StatusCreated, resp)
}
// ── Regenerate / Complete ──────────────────────
// POST /channels/:id/messages/:msgId/regenerate
//
// Two modes depending on target role:
// - assistant message → creates a NEW assistant sibling (same parent_id).
// Context is root → target's parent. ("Give me a different response.")
// - user message → creates a child assistant response.
// Context is root → target. ("Respond to this message.")
//
// The second mode is used after editing: the edit endpoint creates a
// sibling user message, then the frontend calls regenerate on it to
// get an assistant response without duplicating the user message.
func (h *MessageHandler) Regenerate(c *gin.Context) {
var req regenerateRequest
@@ -396,18 +315,14 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
channelID := c.Param("id")
messageID := c.Param("msgId")
if !userOwnsChannel(c, channelID, userID) {
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Load target message
var targetParentID *string
var targetRole string
err := database.DB.QueryRow(database.Q(`
SELECT parent_id, role FROM messages
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
`), messageID, channelID).Scan(&targetParentID, &targetRole)
ctx := c.Request.Context()
// Load target message
targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
@@ -421,13 +336,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
return
}
// Determine context path and new message's parent based on target role:
//
// assistant target: context = root → target's parent (exclude target)
// new parent = target's parent (sibling of target)
//
// user target: context = root → target (include target)
// new parent = target itself (child of target)
// Determine context path and new message's parent based on target role
var contextPath []PathMessage
var newParentID *string
@@ -486,15 +395,14 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// Fallback: channel's stored model
if model == "" {
var channelModel *string
_ = database.DB.QueryRow(database.Q(`SELECT model FROM channels WHERE id = $1`), channelID).Scan(&channelModel)
channelModel, _ := h.stores.Channels.GetDefaultModel(ctx, channelID)
if channelModel != nil {
model = *channelModel
}
}
providerCfg, providerID, model, configID, providerScope, err := comp.resolveConfig(userID, channelID, completionRequest{
Model: model,
Model: model,
ProviderConfigID: providerConfigID,
})
if err != nil {
@@ -514,8 +422,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
if personaSystemPrompt != "" {
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: personaSystemPrompt})
} else {
var systemPrompt *string
_ = database.DB.QueryRow(database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID).Scan(&systemPrompt)
systemPrompt, _ := h.stores.Channels.GetSystemPrompt(ctx, channelID)
if systemPrompt != nil && *systemPrompt != "" {
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: *systemPrompt})
}
@@ -544,14 +451,10 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
}
// Attach tool definitions (same as normal completion)
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(ctx, channelID)
// Query channel metadata for tool context and execution context
var chType string
var chTeamID *string
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
`), channelID).Scan(&chType, &chTeamID)
// Query channel metadata for tool context
chType, chTeamID, _ := h.stores.Channels.GetTypeAndTeamID(ctx, channelID)
msgTeamID := ""
if chTeamID != nil {
msgTeamID = *chTeamID
@@ -559,7 +462,6 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
// v0.25.0: Build ToolContext with channel metadata
tctx := tools.ToolContext{
ChannelType: chType,
WorkspaceID: workspaceID,
@@ -567,12 +469,11 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
PersonaID: personaID,
IsVisitor: isSessionAuth(c),
}
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
provReq.Tools = comp.buildToolDefs(ctx, userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
}
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
// ── Stream the response ──
// Apply provider-specific request hooks (v0.22.1)
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(providerCfg, &provReq)
}
@@ -583,47 +484,34 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
if result.Content != "" {
siblingIdx := nextSiblingIndex(channelID, newParentID)
// Match persistMessage pattern: nil interface{} → SQL NULL,
// non-nil string → valid JSONB. A nil json.RawMessage ([]byte)
// is sent by pq as empty bytes, not NULL, causing "invalid input
// syntax for type json".
var tcVal interface{}
var toolCalls models.JSONMap
if len(result.ToolActivity) > 0 {
b, _ := json.Marshal(result.ToolActivity)
tcVal = string(b)
_ = json.Unmarshal(b, &toolCalls)
}
if toolCalls == nil {
toolCalls = models.JSONMap{}
}
var newID string
if database.IsSQLite() {
newID = store.NewID()
_, err := database.DB.Exec(`
INSERT INTO messages (id, channel_id, role, content, model, tool_calls,
parent_id, participant_type, participant_id, sibling_index)
VALUES (?, ?, 'assistant', ?, ?, ?, ?, 'model', ?, ?)
`, newID, channelID, result.Content, model, tcVal, newParentID, model, siblingIdx)
if err != nil {
log.Printf("Failed to persist regenerated message: %v", err)
newID = ""
}
msg := &models.Message{
ChannelID: channelID,
Role: "assistant",
Content: result.Content,
Model: model,
ToolCalls: toolCalls,
Metadata: models.JSONMap{},
ParentID: newParentID,
SiblingIndex: siblingIdx,
ParticipantType: "model",
ParticipantID: model,
}
if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil {
log.Printf("Failed to persist regenerated message: %v", err)
} else {
err := database.DB.QueryRow(`
INSERT INTO messages (channel_id, role, content, model, tool_calls,
parent_id, participant_type, participant_id, sibling_index)
VALUES ($1, 'assistant', $2, $3, $4, $5, 'model', $6, $7)
RETURNING id
`, channelID, result.Content, model, tcVal, newParentID, model, siblingIdx).Scan(&newID)
if err != nil {
log.Printf("Failed to persist regenerated message: %v", err)
newID = ""
}
}
if newID != "" {
_ = updateCursor(channelID, userID, newID)
flusher, _ := c.Writer.(http.Flusher)
msgJSON, _ := json.Marshal(gin.H{
"id": newID,
"id": msg.ID,
"parent_id": newParentID,
"sibling_index": siblingIdx,
"sibling_count": getSiblingCount(channelID, newParentID),
@@ -633,8 +521,6 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
flusher.Flush()
}
}
_, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID)
}
// Log usage for regeneration
@@ -645,9 +531,6 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// ── Switch Branch (update cursor) ───────────
// PUT /channels/:id/cursor
//
// Navigates to a different branch. If the target message has children,
// follows the first-child chain to find the leaf.
func (h *MessageHandler) UpdateCursor(c *gin.Context) {
var req cursorRequest
@@ -659,17 +542,15 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if !userOwnsChannel(c, channelID, userID) {
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Verify the target message belongs to this channel
var msgChannelID string
err := database.DB.QueryRow(database.Q(`
SELECT channel_id FROM messages
WHERE id = $1 AND deleted_at IS NULL
`), req.ActiveLeafID).Scan(&msgChannelID)
ctx := c.Request.Context()
// Verify the target message belongs to this channel and is not deleted.
// GetParentAndRole does: WHERE id=$1 AND channel_id=$2 AND deleted_at IS NULL
_, _, err := h.stores.Messages.GetParentAndRole(ctx, req.ActiveLeafID, channelID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
@@ -678,10 +559,6 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify message"})
return
}
if msgChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "message does not belong to this channel"})
return
}
// Walk down to the leaf from the target
leafID, err := findLeafFromMessage(req.ActiveLeafID)
@@ -707,16 +584,13 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
// ── List Siblings ───────────────────────────
// GET /channels/:id/messages/:msgId/siblings
//
// Returns all siblings of a message (messages sharing the same parent_id).
// Used by the branch navigator to populate the arrow navigation.
func (h *MessageHandler) ListSiblings(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
messageID := c.Param("msgId")
if !userOwnsChannel(c, channelID, userID) {
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
@@ -733,36 +607,34 @@ func (h *MessageHandler) ListSiblings(c *gin.Context) {
})
}
// ── Ownership Check ─────────────────────────
// ── Ownership / Access Check ─────────────────
func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
var ownerID string
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 false
}
// userCanAccessChannel verifies channel ownership or participation,
// writing an error response if denied. Returns true if access is allowed.
func userCanAccessChannel(c *gin.Context, stores store.Stores, channelID, userID string) bool {
ok, err := stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel ownership"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"})
return false
}
if ownerID == userID {
return true
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false
}
// v0.23.2: Also allow if user is a participant (multi-user DMs, channels)
var exists bool
_ = database.DB.QueryRow(
database.Q(`SELECT EXISTS(SELECT 1 FROM channel_participants WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2)`),
channelID, userID,
).Scan(&exists)
if exists {
return true
}
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false
return true
}
// userOwnsChannel is the legacy wrapper used by other handlers (completion.go).
// Delegates to userCanAccessChannel using the treepath global stores.
func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
ok, err := treepath.Stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"})
return false
}
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false
}
return true
}