641 lines
19 KiB
Go
641 lines
19 KiB
Go
package handlers
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
|
"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 ────────────────
|
|
|
|
type createMessageRequest struct {
|
|
Role string `json:"role" binding:"required,oneof=user assistant system"`
|
|
Content string `json:"content" binding:"required"`
|
|
Model string `json:"model,omitempty"`
|
|
}
|
|
|
|
type messageResponse struct {
|
|
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"`
|
|
SiblingCount int `json:"sibling_count"`
|
|
SiblingIndex int `json:"sibling_index"`
|
|
ParticipantType *string `json:"participant_type,omitempty"`
|
|
ParticipantID *string `json:"participant_id,omitempty"`
|
|
SenderName *string `json:"sender_name,omitempty"`
|
|
SenderAvatar *string `json:"sender_avatar,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
type editRequest struct {
|
|
Content string `json:"content" binding:"required"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
type cursorRequest struct {
|
|
ActiveLeafID string `json:"active_leaf_id" binding:"required"`
|
|
}
|
|
|
|
// MessageHandler holds dependencies for message endpoints.
|
|
type MessageHandler struct {
|
|
vault *crypto.KeyResolver
|
|
stores store.Stores
|
|
hub *events.Hub
|
|
objStore storage.ObjectStore
|
|
}
|
|
|
|
// NewMessageHandler creates a new message handler.
|
|
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *MessageHandler {
|
|
return &MessageHandler{vault: vault, stores: stores, hub: hub, objStore: objStore}
|
|
}
|
|
|
|
// ── List Messages (flat, all branches) ──────
|
|
// GET /channels/:id/messages
|
|
|
|
func (h *MessageHandler) ListMessages(c *gin.Context) {
|
|
page, perPage, offset := parsePagination(c)
|
|
|
|
userID := getUserID(c)
|
|
channelID := c.Param("id")
|
|
|
|
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
|
|
if isSessionAuth(c) {
|
|
if !sessionCanAccessChannel(c, channelID) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
|
|
return
|
|
}
|
|
} else if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
// Enrich with sibling counts
|
|
for i := range messages {
|
|
messages[i].SiblingCount = getSiblingCount(channelID, messages[i].ParentID)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, paginatedResponse{
|
|
Data: messages,
|
|
Page: page,
|
|
PerPage: perPage,
|
|
Total: total,
|
|
TotalPages: int(math.Ceil(float64(total) / float64(perPage))),
|
|
})
|
|
}
|
|
|
|
// ── Active Path ─────────────────────────────
|
|
// GET /channels/:id/path
|
|
|
|
func (h *MessageHandler) GetActivePath(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
channelID := c.Param("id")
|
|
|
|
if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
|
return
|
|
}
|
|
|
|
path, err := getActivePath(channelID, userID)
|
|
if err != nil {
|
|
log.Printf("GetActivePath error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"messages": path})
|
|
}
|
|
|
|
// ── Create Message (manual) ─────────────────
|
|
// POST /channels/:id/messages
|
|
|
|
func (h *MessageHandler) CreateMessage(c *gin.Context) {
|
|
var req createMessageRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
userID := getUserID(c)
|
|
channelID := c.Param("id")
|
|
|
|
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
|
|
if isSessionAuth(c) {
|
|
if !sessionCanAccessChannel(c, channelID) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
|
|
return
|
|
}
|
|
} else if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
|
return
|
|
}
|
|
|
|
// Use cursor for parent, compute sibling_index
|
|
effectiveUserID := userID
|
|
if isSessionAuth(c) {
|
|
effectiveUserID = c.GetString("session_id")
|
|
}
|
|
parentID, _ := getActiveLeaf(channelID, effectiveUserID)
|
|
siblingIdx := nextSiblingIndex(channelID, parentID)
|
|
|
|
participantType := "user"
|
|
participantID := userID
|
|
if isSessionAuth(c) {
|
|
participantType = "session"
|
|
participantID = c.GetString("session_id")
|
|
}
|
|
if req.Role == "assistant" {
|
|
participantType = "model"
|
|
if req.Model != "" {
|
|
participantID = req.Model
|
|
}
|
|
}
|
|
|
|
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{},
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
|
|
func (h *MessageHandler) EditMessage(c *gin.Context) {
|
|
var req editRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
userID := getUserID(c)
|
|
channelID := c.Param("id")
|
|
messageID := c.Param("msgId")
|
|
|
|
if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"})
|
|
return
|
|
}
|
|
if targetRole != "user" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "can only edit user messages"})
|
|
return
|
|
}
|
|
|
|
// Create sibling: same parent_id as the target
|
|
siblingIdx := nextSiblingIndex(channelID, targetParentID)
|
|
|
|
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 := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"})
|
|
return
|
|
}
|
|
|
|
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, resp)
|
|
}
|
|
|
|
// ── Regenerate / Complete ──────────────────────
|
|
// POST /channels/:id/messages/:msgId/regenerate
|
|
|
|
func (h *MessageHandler) Regenerate(c *gin.Context) {
|
|
var req regenerateRequest
|
|
_ = c.ShouldBindJSON(&req) // body is optional
|
|
|
|
userID := getUserID(c)
|
|
channelID := c.Param("id")
|
|
messageID := c.Param("msgId")
|
|
|
|
if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"})
|
|
return
|
|
}
|
|
if targetRole != "assistant" && targetRole != "user" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "can only regenerate user or assistant messages"})
|
|
return
|
|
}
|
|
|
|
// Determine context path and new message's parent based on target role
|
|
var contextPath []PathMessage
|
|
var newParentID *string
|
|
|
|
if targetRole == "assistant" {
|
|
if targetParentID == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot regenerate root message"})
|
|
return
|
|
}
|
|
contextPath, err = getPathToLeaf(channelID, *targetParentID)
|
|
newParentID = targetParentID
|
|
} else {
|
|
// user message — respond to it
|
|
contextPath, err = getPathToLeaf(channelID, messageID)
|
|
newParentID = &messageID
|
|
}
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build context"})
|
|
return
|
|
}
|
|
|
|
// ── Resolve model + provider ──
|
|
|
|
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore, nil)
|
|
|
|
var personaSystemPrompt string
|
|
var personaID string
|
|
model := req.Model
|
|
providerConfigID := req.ProviderConfigID
|
|
temperature := req.Temperature
|
|
maxTokens := req.MaxTokens
|
|
|
|
if req.PersonaID != "" {
|
|
persona := ResolvePersona(h.stores, req.PersonaID, userID)
|
|
if persona == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "persona not found"})
|
|
return
|
|
}
|
|
personaID = persona.ID
|
|
if model == "" {
|
|
model = persona.BaseModelID
|
|
}
|
|
if providerConfigID == "" && persona.ProviderConfigID != nil {
|
|
providerConfigID = *persona.ProviderConfigID
|
|
}
|
|
if temperature == nil && persona.Temperature != nil {
|
|
temperature = persona.Temperature
|
|
}
|
|
if maxTokens == 0 && persona.MaxTokens != nil {
|
|
maxTokens = *persona.MaxTokens
|
|
}
|
|
if persona.SystemPrompt != "" {
|
|
personaSystemPrompt = persona.SystemPrompt
|
|
}
|
|
}
|
|
|
|
// Fallback: channel's stored model
|
|
if model == "" {
|
|
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,
|
|
ProviderConfigID: providerConfigID,
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
provider, err := providers.Get(providerID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Build LLM message array
|
|
llmMessages := make([]providers.Message, 0, len(contextPath)+1)
|
|
|
|
if personaSystemPrompt != "" {
|
|
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: personaSystemPrompt})
|
|
} else {
|
|
systemPrompt, _ := h.stores.Channels.GetSystemPrompt(ctx, channelID)
|
|
if systemPrompt != nil && *systemPrompt != "" {
|
|
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: *systemPrompt})
|
|
}
|
|
}
|
|
|
|
for _, m := range contextPath {
|
|
if m.Role == "system" {
|
|
continue
|
|
}
|
|
llmMessages = append(llmMessages, providers.Message{Role: m.Role, Content: m.Content})
|
|
}
|
|
|
|
provReq := providers.CompletionRequest{
|
|
Model: model,
|
|
Messages: llmMessages,
|
|
}
|
|
|
|
caps := comp.getModelCapabilities(c, model, configID)
|
|
if maxTokens > 0 {
|
|
provReq.MaxTokens = maxTokens
|
|
} else {
|
|
provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps)
|
|
}
|
|
if temperature != nil {
|
|
provReq.Temperature = temperature
|
|
}
|
|
|
|
// Attach tool definitions (same as normal completion)
|
|
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(ctx, channelID)
|
|
|
|
// Query channel metadata for tool context
|
|
chType, chTeamID, _ := h.stores.Channels.GetTypeAndTeamID(ctx, channelID)
|
|
msgTeamID := ""
|
|
if chTeamID != nil {
|
|
msgTeamID = *chTeamID
|
|
}
|
|
|
|
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
|
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
|
tctx := tools.ToolContext{
|
|
ChannelType: chType,
|
|
WorkspaceID: workspaceID,
|
|
TeamID: msgTeamID,
|
|
PersonaID: personaID,
|
|
IsVisitor: isSessionAuth(c),
|
|
}
|
|
provReq.Tools = comp.buildToolDefs(ctx, userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
|
|
}
|
|
|
|
// ── Stream the response ──
|
|
|
|
if hooks := providers.GetHooks(providerID); hooks != nil {
|
|
hooks.PreRequest(providerCfg, &provReq)
|
|
}
|
|
|
|
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, providerID, userID, channelID, personaID, workspaceID, configID, msgTeamID, h.hub, comp.health)
|
|
|
|
// Persist as sibling (regen) with tool activity
|
|
if result.Content != "" {
|
|
siblingIdx := nextSiblingIndex(channelID, newParentID)
|
|
|
|
var toolCalls models.JSONMap
|
|
if len(result.ToolActivity) > 0 {
|
|
b, _ := json.Marshal(result.ToolActivity)
|
|
_ = json.Unmarshal(b, &toolCalls)
|
|
}
|
|
if toolCalls == nil {
|
|
toolCalls = models.JSONMap{}
|
|
}
|
|
|
|
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 {
|
|
flusher, _ := c.Writer.(http.Flusher)
|
|
msgJSON, _ := json.Marshal(gin.H{
|
|
"id": msg.ID,
|
|
"parent_id": newParentID,
|
|
"sibling_index": siblingIdx,
|
|
"sibling_count": getSiblingCount(channelID, newParentID),
|
|
})
|
|
fmt.Fprintf(c.Writer, "data: {\"switchboard_meta\":%s}\n\n", msgJSON)
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Log usage for regeneration
|
|
comp.logUsage(c, channelID, userID, configID, providerScope, model,
|
|
result.InputTokens, result.OutputTokens,
|
|
result.CacheCreationTokens, result.CacheReadTokens)
|
|
}
|
|
|
|
// ── Switch Branch (update cursor) ───────────
|
|
// PUT /channels/:id/cursor
|
|
|
|
func (h *MessageHandler) UpdateCursor(c *gin.Context) {
|
|
var req cursorRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
userID := getUserID(c)
|
|
channelID := c.Param("id")
|
|
|
|
if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify message"})
|
|
return
|
|
}
|
|
|
|
// Walk down to the leaf from the target
|
|
leafID, err := findLeafFromMessage(req.ActiveLeafID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to find leaf"})
|
|
return
|
|
}
|
|
|
|
if err := updateCursor(channelID, userID, leafID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update cursor"})
|
|
return
|
|
}
|
|
|
|
// Return the new active path
|
|
path, err := getPathToLeaf(channelID, leafID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"messages": path, "active_leaf_id": leafID})
|
|
}
|
|
|
|
// ── List Siblings ───────────────────────────
|
|
// GET /channels/:id/messages/:msgId/siblings
|
|
|
|
func (h *MessageHandler) ListSiblings(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
channelID := c.Param("id")
|
|
messageID := c.Param("msgId")
|
|
|
|
if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
|
return
|
|
}
|
|
|
|
siblings, currentIdx, err := getSiblings(messageID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"siblings": siblings,
|
|
"current_index": currentIdx,
|
|
"total": len(siblings),
|
|
})
|
|
}
|
|
|
|
// ── Ownership / Access Check ─────────────────
|
|
|
|
// 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 access"})
|
|
return false
|
|
}
|
|
if !ok {
|
|
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
|
|
}
|