package handlers import ( "encoding/json" "math" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Request / Response types ──────────────── type createChannelRequest struct { Title string `json:"title" binding:"required,max=500"` Type string `json:"type,omitempty"` // direct (default), dm, group, channel, workflow Description string `json:"description,omitempty"` Model string `json:"model,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` ProviderConfigID *string `json:"provider_config_id,omitempty"` Folder string `json:"folder,omitempty"` FolderID *string `json:"folder_id,omitempty"` Tags []string `json:"tags,omitempty"` // v0.23.2: DM creation Participants []string `json:"participants,omitempty"` // user IDs for DM (exactly 1 other user) AiMode string `json:"ai_mode,omitempty"` // auto (default), mention_only, off } type updateChannelRequest struct { Title *string `json:"title,omitempty"` Description *string `json:"description,omitempty"` Model *string `json:"model,omitempty"` SystemPrompt *string `json:"system_prompt,omitempty"` ProviderConfigID *string `json:"provider_config_id,omitempty"` IsArchived *bool `json:"is_archived,omitempty"` IsPinned *bool `json:"is_pinned,omitempty"` Folder *string `json:"folder,omitempty"` FolderID *string `json:"folder_id,omitempty"` Tags []string `json:"tags,omitempty"` Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5) AiMode *string `json:"ai_mode,omitempty"` // v0.23.2: auto, mention_only, off Topic *string `json:"topic,omitempty"` // v0.23.2: channel topic } type channelResponse struct { ID string `json:"id"` UserID string `json:"user_id"` Title string `json:"title"` Type string `json:"type"` AiMode string `json:"ai_mode,omitempty"` Topic *string `json:"topic,omitempty"` Description *string `json:"description"` Model *string `json:"model"` ProviderConfigID *string `json:"provider_config_id"` SystemPrompt *string `json:"system_prompt"` IsArchived bool `json:"is_archived"` IsPinned bool `json:"is_pinned"` Folder *string `json:"folder"` FolderID *string `json:"folder_id,omitempty"` ProjectID *string `json:"project_id,omitempty"` WorkspaceID *string `json:"workspace_id,omitempty"` Tags []string `json:"tags"` Settings json.RawMessage `json:"settings,omitempty"` MessageCount int `json:"message_count"` UnreadCount int `json:"unread_count,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type paginatedResponse struct { Data interface{} `json:"data"` Page int `json:"page"` PerPage int `json:"per_page"` Total int `json:"total"` TotalPages int `json:"total_pages"` } // ChannelHandler holds dependencies for channel endpoints. type ChannelHandler struct { stores store.Stores } // NewChannelHandler creates a new channel handler. func NewChannelHandler(stores store.Stores) *ChannelHandler { return &ChannelHandler{stores: stores} } // channelDeleteHook is called after a channel is successfully deleted. // Set at startup by SetChannelDeleteHook to clean up storage files. var channelDeleteHook func(channelID string) // SetChannelDeleteHook registers a callback invoked after channel deletion. // Used to clean up channel files on the storage backend. func SetChannelDeleteHook(fn func(channelID string)) { channelDeleteHook = fn } // ── Helpers ───────────────────────────────── // getUserID extracts the authenticated user's ID from context. func getUserID(c *gin.Context) string { uid, _ := c.Get("user_id") s, _ := uid.(string) return s } // isSessionAuth returns true if the request is from a session participant (v0.24.3). func isSessionAuth(c *gin.Context) bool { return c.GetString("auth_type") == "session" } // sessionCanAccessChannel validates that a session participant is authorized // for the given channel. func sessionCanAccessChannel(c *gin.Context, channelID string) bool { if !isSessionAuth(c) { return false } return c.GetString("channel_id") == channelID } // parsePagination reads page and per_page from query params with defaults. func parsePagination(c *gin.Context) (page, perPage, offset int) { page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ = strconv.Atoi(c.DefaultQuery("per_page", "50")) if page < 1 { page = 1 } if perPage < 1 { perPage = 50 } if perPage > 100 { perPage = 100 } offset = (page - 1) * perPage return } // listItemToResponse converts a store.ChannelListItem to a handler response. func listItemToResponse(item store.ChannelListItem) channelResponse { tags := item.Tags if tags == nil { tags = []string{} } return channelResponse{ ID: item.ID, UserID: item.UserID, Title: item.Title, Type: item.Type, AiMode: item.AiMode, Topic: item.Topic, Description: item.Description, Model: item.Model, ProviderConfigID: item.ProviderConfigID, SystemPrompt: item.SystemPrompt, IsArchived: item.IsArchived, IsPinned: item.IsPinned, Folder: item.Folder, FolderID: item.FolderID, ProjectID: item.ProjectID, WorkspaceID: item.WorkspaceID, Tags: tags, Settings: item.Settings, MessageCount: item.MessageCount, UnreadCount: item.UnreadCount, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } } // ── List Channels ─────────────────────────── func (h *ChannelHandler) ListChannels(c *gin.Context) { userID := getUserID(c) page, perPage, offset := parsePagination(c) // Optional filters archived := c.DefaultQuery("archived", "false") var channelTypes []string if raw := c.Query("types"); raw != "" { for _, t := range strings.Split(raw, ",") { t = strings.TrimSpace(t) if t != "" { channelTypes = append(channelTypes, t) } } } if len(channelTypes) == 0 { if ct := c.DefaultQuery("type", ""); ct != "" { channelTypes = []string{ct} } } filter := store.ChannelListFilter{ ListOptions: store.ListOptions{Limit: perPage, Offset: offset}, Archived: archived == "true", Types: channelTypes, Folder: c.Query("folder"), FolderID: c.Query("folder_id"), Search: strings.TrimSpace(c.Query("search")), ProjectID: c.Query("project_id"), } items, total, err := h.stores.Channels.ListFiltered(c.Request.Context(), userID, filter) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"}) return } channels := make([]channelResponse, 0, len(items)) for _, item := range items { channels = append(channels, listItemToResponse(item)) } SafeJSON(c, http.StatusOK, paginatedResponse{ Data: channels, Page: page, PerPage: perPage, Total: total, TotalPages: int(math.Ceil(float64(total) / float64(perPage))), }) } // ── Create Channel ────────────────────────── func (h *ChannelHandler) CreateChannel(c *gin.Context) { userID := getUserID(c) var req createChannelRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Tags == nil { req.Tags = []string{} } // Default type is "direct" (1:1 AI chat) channelType := req.Type if channelType == "" { channelType = "direct" } // Default ai_mode: mention_only for DMs, auto for everything else aiMode := req.AiMode if aiMode == "" { if channelType == "dm" { aiMode = "mention_only" } else { aiMode = "auto" } } ctx := c.Request.Context() // ── DM dedup: check for existing DM between these two users ── if channelType == "dm" && len(req.Participants) == 1 { otherUserID := req.Participants[0] if otherUserID == userID { c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create DM with yourself"}) return } existingID, _ := h.stores.Channels.FindExistingDM(ctx, userID, otherUserID) if existingID != "" { // Return existing channel instead of creating duplicate item, err := h.stores.Channels.GetForUser(ctx, existingID, userID) if err == nil { SafeJSON(c, http.StatusOK, listItemToResponse(*item)) // 200, not 201 — existing resource return } // If read fails, fall through and create new (shouldn't happen) } } // Build channel model ch := &models.Channel{ UserID: userID, Title: req.Title, Type: channelType, Description: req.Description, Model: req.Model, SystemPrompt: req.SystemPrompt, ProviderConfigID: req.ProviderConfigID, FolderID: req.FolderID, } dmPartners := []string{} if channelType == "dm" { dmPartners = req.Participants } defaultConfigID := "" if req.ProviderConfigID != nil { defaultConfigID = *req.ProviderConfigID } if err := h.stores.Channels.CreateFull(ctx, ch, req.Folder, req.Tags, aiMode, userID, dmPartners, req.Model, defaultConfigID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"}) return } // Fetch full response with message count item, err := h.stores.Channels.GetForUser(ctx, ch.ID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created channel"}) return } SafeJSON(c, http.StatusCreated, listItemToResponse(*item)) } // ── Get Channel ───────────────────────────── func (h *ChannelHandler) GetChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") item, err := h.stores.Channels.GetForUser(c.Request.Context(), channelID, userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } SafeJSON(c, http.StatusOK, listItemToResponse(*item)) } // ── Update Channel ────────────────────────── func (h *ChannelHandler) UpdateChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") ctx := c.Request.Context() var req updateChannelRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Verify ownership owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } if !owns { c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) return } // Build fields map for store.Update fields := map[string]interface{}{} if req.Title != nil { fields["title"] = *req.Title } if req.Description != nil { fields["description"] = *req.Description } if req.Model != nil { fields["model"] = *req.Model } if req.SystemPrompt != nil { fields["system_prompt"] = *req.SystemPrompt } if req.ProviderConfigID != nil { if *req.ProviderConfigID == "" { fields["provider_config_id"] = nil } else { fields["provider_config_id"] = *req.ProviderConfigID } } if req.IsArchived != nil { fields["is_archived"] = *req.IsArchived } if req.IsPinned != nil { fields["is_pinned"] = *req.IsPinned } if req.Folder != nil { fields["folder"] = *req.Folder } if req.FolderID != nil { if *req.FolderID == "" { fields["folder_id"] = nil // unbind from folder } else { fields["folder_id"] = *req.FolderID } } if req.Tags != nil { fields["tags"] = req.Tags } if req.WorkspaceID != nil { if *req.WorkspaceID == "" { fields["workspace_id"] = nil // unbind } else { fields["workspace_id"] = *req.WorkspaceID } } if req.AiMode != nil { mode := *req.AiMode if mode != "auto" && mode != "mention_only" && mode != "off" { c.JSON(http.StatusBadRequest, gin.H{"error": "ai_mode must be auto, mention_only, or off"}) return } fields["ai_mode"] = mode } if req.Topic != nil { fields["topic"] = *req.Topic } hasFields := len(fields) > 0 hasSettings := req.Settings != nil if !hasFields && !hasSettings { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } if hasSettings { if !json.Valid([]byte(*req.Settings)) { c.JSON(http.StatusBadRequest, gin.H{"error": "settings must be valid JSON"}) return } } if hasFields { if err := h.stores.Channels.Update(ctx, channelID, fields); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"}) return } } if hasSettings { if err := h.stores.Channels.MergeSettings(ctx, channelID, json.RawMessage(*req.Settings)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"}) return } } // Return updated channel h.GetChannel(c) } // ── Delete Channel ────────────────────────── func (h *ChannelHandler) DeleteChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") n, err := h.stores.Channels.DeleteByOwner(c.Request.Context(), channelID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"}) return } if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } // Clean up storage files (CASCADE already removed PG file rows) if channelDeleteHook != nil { go channelDeleteHook(channelID) } c.JSON(http.StatusOK, gin.H{"message": "channel deleted"}) } // ── Mark Read (v0.23.2) ─────────────────────── func (h *ChannelHandler) MarkRead(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") err := h.stores.Channels.MarkRead(c.Request.Context(), channelID, userID) if err != nil { // Participant row may not exist for legacy direct chats — that's OK } c.JSON(http.StatusOK, gin.H{"ok": true}) }