package handlers import ( "database/sql" "net/http" "github.com/gin-gonic/gin" "chat-switchboard/models" "chat-switchboard/store" ) // ── Channel Models Handler ────────────────── // Manages multi-model assignments per channel (v0.20.0 Phase 2). // Routes: // GET /channels/:id/models — list // POST /channels/:id/models — add // PATCH /channels/:id/models/:modelId — update (display_name, is_default, system_prompt) // DELETE /channels/:id/models/:modelId — remove const maxModelsPerChannel = 5 type ChannelModelHandler struct { stores store.Stores } func NewChannelModelHandler(stores store.Stores) *ChannelModelHandler { return &ChannelModelHandler{stores: stores} } // ── List ───────────────────────────────────── func (h *ChannelModelHandler) List(c *gin.Context) { channelID := c.Param("id") userID := getUserID(c) if !userOwnsChannel(c, channelID, userID) { return } roster, err := h.stores.Channels.GetModels(c.Request.Context(), channelID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"}) return } if roster == nil { roster = []models.ChannelModel{} } c.JSON(http.StatusOK, gin.H{"models": roster}) } // ── Add ────────────────────────────────────── type addChannelModelRequest struct { ModelID string `json:"model_id" binding:"required"` ProviderConfigID string `json:"provider_config_id"` DisplayName string `json:"display_name" binding:"required"` SystemPrompt string `json:"system_prompt"` IsDefault bool `json:"is_default"` } func (h *ChannelModelHandler) Add(c *gin.Context) { channelID := c.Param("id") userID := getUserID(c) if !userOwnsChannel(c, channelID, userID) { return } var req addChannelModelRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Check max models limit existing, err := h.stores.Channels.GetModels(c.Request.Context(), channelID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check existing models"}) return } if len(existing) >= maxModelsPerChannel { c.JSON(http.StatusConflict, gin.H{"error": "maximum models per channel reached"}) return } // If this is the first model or marked as default, ensure only one default isDefault := req.IsDefault || len(existing) == 0 cm := &models.ChannelModel{ ChannelID: channelID, ModelID: req.ModelID, ProviderConfigID: req.ProviderConfigID, DisplayName: req.DisplayName, SystemPrompt: req.SystemPrompt, IsDefault: isDefault, } // SetModel handles INSERT ON CONFLICT (upsert by channel_id + model_id) if err := h.stores.Channels.SetModel(c.Request.Context(), cm); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add model"}) return } // If this is the new default, clear default on others if isDefault { h.clearOtherDefaults(c, channelID, req.ModelID) } // Return the updated roster roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) if roster == nil { roster = []models.ChannelModel{} } c.JSON(http.StatusCreated, gin.H{"models": roster}) } // ── Update ─────────────────────────────────── type updateChannelModelRequest struct { DisplayName *string `json:"display_name"` SystemPrompt *string `json:"system_prompt"` IsDefault *bool `json:"is_default"` } func (h *ChannelModelHandler) Update(c *gin.Context) { channelID := c.Param("id") modelRecordID := c.Param("modelId") userID := getUserID(c) if !userOwnsChannel(c, channelID, userID) { return } var req updateChannelModelRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Verify model belongs to this channel cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "model not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"}) return } if cm.ChannelID != channelID { c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"}) return } fields := make(map[string]interface{}) if req.DisplayName != nil { fields["display_name"] = *req.DisplayName } if req.SystemPrompt != nil { fields["system_prompt"] = *req.SystemPrompt } if req.IsDefault != nil { fields["is_default"] = *req.IsDefault if *req.IsDefault { h.clearOtherDefaults(c, channelID, cm.ModelID) } } if len(fields) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } if err := h.stores.Channels.UpdateModel(c.Request.Context(), modelRecordID, fields); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update model"}) return } roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) if roster == nil { roster = []models.ChannelModel{} } c.JSON(http.StatusOK, gin.H{"models": roster}) } // ── Delete ─────────────────────────────────── func (h *ChannelModelHandler) Delete(c *gin.Context) { channelID := c.Param("id") modelRecordID := c.Param("modelId") userID := getUserID(c) if !userOwnsChannel(c, channelID, userID) { return } // Verify model belongs to this channel cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "model not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"}) return } if cm.ChannelID != channelID { c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"}) return } if err := h.stores.Channels.DeleteModel(c.Request.Context(), modelRecordID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"}) return } // If deleted model was default, promote the first remaining model if cm.IsDefault { remaining, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) if len(remaining) > 0 { h.stores.Channels.UpdateModel(c.Request.Context(), remaining[0].ID, map[string]interface{}{"is_default": true}) } } roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) if roster == nil { roster = []models.ChannelModel{} } c.JSON(http.StatusOK, gin.H{"models": roster}) } // ── Helpers ────────────────────────────────── // clearOtherDefaults sets is_default=false on all models in the channel // except the one with the given modelID. func (h *ChannelModelHandler) clearOtherDefaults(c *gin.Context, channelID, keepModelID string) { roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) for _, m := range roster { if m.ModelID != keepModelID && m.IsDefault { h.stores.Channels.UpdateModel(c.Request.Context(), m.ID, map[string]interface{}{"is_default": false}) } } }