package handlers import ( "database/sql" "math" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/lib/pq" "git.gobha.me/xcaliber/chat-switchboard/database" ) // ── Request / Response types ──────────────── 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"` Folder string `json:"folder,omitempty"` Tags []string `json:"tags,omitempty"` } 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"` IsArchived *bool `json:"is_archived,omitempty"` IsPinned *bool `json:"is_pinned,omitempty"` Folder *string `json:"folder,omitempty"` Tags []string `json:"tags,omitempty"` } 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"` IsArchived bool `json:"is_archived"` IsPinned bool `json:"is_pinned"` Folder *string `json:"folder"` Tags []string `json:"tags"` MessageCount int `json:"message_count"` 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{} // NewChannelHandler creates a new channel handler. func NewChannelHandler() *ChannelHandler { return &ChannelHandler{} } // ── 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 } // 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 } // ── List Channels ─────────────────────────── 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 search := strings.TrimSpace(c.Query("search")) // Count total 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 = $` + strconv.Itoa(argN) countArgs = append(countArgs, folder) argN++ } if search != "" { countQuery += ` AND title ILIKE $` + strconv.Itoa(argN) countArgs = append(countArgs, "%"+search+"%") 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 channels"}) return } // Fetch channels with message count query := ` 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 channels c LEFT JOIN ( 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 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) argN++ } if search != "" { query += ` AND c.title ILIKE $` + strconv.Itoa(argN) args = append(args, "%"+search+"%") argN++ } 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...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"}) return } defer rows.Close() channels := make([]channelResponse, 0) for rows.Next() { var ch channelResponse var tags []string 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.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan channel"}) return } if tags == nil { tags = []string{} } ch.Tags = tags channels = append(channels, ch) } c.JSON(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" } var ch channelResponse var tags []string err := database.DB.QueryRow(` 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, 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.CreatedAt, &ch.UpdatedAt, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"}) return } if tags == nil { tags = []string{} } ch.Tags = tags 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) // 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 Channel ───────────────────────────── func (h *ChannelHandler) GetChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") var ch channelResponse var tags []string err := database.DB.QueryRow(` 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 channels c LEFT JOIN ( 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( &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.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, ) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get channel"}) return } if tags == nil { tags = []string{} } ch.Tags = tags c.JSON(http.StatusOK, ch) } // ── Update Channel ────────────────────────── func (h *ChannelHandler) UpdateChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") if database.DB == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"}) return } var req updateChannelRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Verify ownership var ownerID string 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": "channel not found"}) return } if ownerID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) return } // Build dynamic UPDATE setClauses := []string{} args := []interface{}{} argN := 1 addClause := func(col string, val interface{}) { setClauses = append(setClauses, col+" = $"+strconv.Itoa(argN)) args = append(args, val) argN++ } if req.Title != nil { addClause("title", *req.Title) } if req.Description != nil { addClause("description", *req.Description) } if req.Model != nil { addClause("model", *req.Model) } if req.SystemPrompt != nil { addClause("system_prompt", *req.SystemPrompt) } if req.APIConfigID != nil { addClause("api_config_id", *req.APIConfigID) } if req.IsArchived != nil { addClause("is_archived", *req.IsArchived) } if req.IsPinned != nil { addClause("is_pinned", *req.IsPinned) } if req.Folder != nil { addClause("folder", *req.Folder) } if req.Tags != nil { addClause("tags", pq.Array(req.Tags)) } if len(setClauses) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) 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) args = append(args, channelID, userID) _, err = database.DB.Exec(query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"}) return } // Return updated channel h.GetChannel(c) } // ── Delete Channel ────────────────────────── func (h *ChannelHandler) DeleteChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") result, err := database.DB.Exec( `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 channel"}) return } rows, _ := result.RowsAffected() if rows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "channel deleted"}) }