package handlers import ( "database/sql" "log" "net/http" "strings" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" ) // PresetHandler handles model preset CRUD operations. type PresetHandler struct{} // NewPresetHandler creates a new handler. func NewPresetHandler() *PresetHandler { return &PresetHandler{} } // ── Request/Response Types ───────────────── type createPresetRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` BaseModelID string `json:"base_model_id" binding:"required"` APIConfigID *string `json:"api_config_id,omitempty"` SystemPrompt string `json:"system_prompt"` Temperature *float64 `json:"temperature,omitempty"` MaxTokens *int `json:"max_tokens,omitempty"` ToolsEnabled string `json:"tools_enabled,omitempty"` Icon string `json:"icon,omitempty"` IsShared bool `json:"is_shared"` } type updatePresetRequest struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` BaseModelID *string `json:"base_model_id,omitempty"` APIConfigID *string `json:"api_config_id,omitempty"` SystemPrompt *string `json:"system_prompt,omitempty"` Temperature *float64 `json:"temperature,omitempty"` MaxTokens *int `json:"max_tokens,omitempty"` ToolsEnabled *string `json:"tools_enabled,omitempty"` Icon *string `json:"icon,omitempty"` IsShared *bool `json:"is_shared,omitempty"` IsActive *bool `json:"is_active,omitempty"` } type presetResponse struct { models.ModelPreset ProviderName string `json:"provider_name,omitempty"` BaseModelName string `json:"base_model_name,omitempty"` } // ── User Preset Endpoints ────────────────── // These require user_providers_enabled for personal presets. // ListUserPresets returns all presets visible to the user: // their own personal presets + all global presets + shared presets + team presets. // GET /api/v1/presets func (h *PresetHandler) ListUserPresets(c *gin.Context) { userID := getUserID(c) rows, err := database.DB.Query(` SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id, mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled, mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active, mp.icon, mp.avatar, mp.created_at, mp.updated_at, COALESCE(ac.name, '') as provider_name FROM model_presets mp LEFT JOIN api_configs ac ON mp.api_config_id = ac.id WHERE mp.is_active = true AND ( mp.scope = 'global' OR (mp.scope = 'personal' AND mp.created_by = $1) OR (mp.scope = 'personal' AND mp.is_shared = true) OR (mp.scope = 'team' AND mp.team_id IN ( SELECT team_id FROM team_members WHERE user_id = $1 )) ) ORDER BY mp.scope ASC, mp.name ASC `, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list presets"}) return } defer rows.Close() presets := make([]presetResponse, 0) for rows.Next() { var p presetResponse if err := rows.Scan( &p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID, &p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled, &p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive, &p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName, ); err != nil { continue } presets = append(presets, p) } c.JSON(http.StatusOK, gin.H{"presets": presets}) } // CreateUserPreset creates a personal preset for the current user. // POST /api/v1/presets func (h *PresetHandler) CreateUserPreset(c *gin.Context) { userID := getUserID(c) var req createPresetRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } // Validate api_config_id belongs to user if provided if req.APIConfigID != nil && *req.APIConfigID != "" { var count int err := database.DB.QueryRow(` SELECT COUNT(*) FROM api_configs WHERE id = $1 AND (user_id = $2 OR is_global = true) AND is_active = true AND team_id IS NULL `, *req.APIConfigID, userID).Scan(&count) if err != nil || count == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or inaccessible API config"}) return } } toolsJSON := req.ToolsEnabled if toolsJSON == "" { toolsJSON = "[]" } var id string err := database.DB.QueryRow(` INSERT INTO model_presets (name, description, base_model_id, api_config_id, system_prompt, temperature, max_tokens, tools_enabled, scope, created_by, is_shared, icon) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'personal', $9, $10, $11) RETURNING id `, req.Name, req.Description, req.BaseModelID, req.APIConfigID, req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON, userID, req.IsShared, req.Icon, ).Scan(&id) if err != nil { log.Printf("[WARN] Failed to create user preset: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create preset: " + err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"id": id}) } // UpdateUserPreset updates a personal preset owned by the current user. // PUT /api/v1/presets/:id func (h *PresetHandler) UpdateUserPreset(c *gin.Context) { userID := getUserID(c) presetID := c.Param("id") // Verify ownership var createdBy, scope string err := database.DB.QueryRow( `SELECT created_by, scope FROM model_presets WHERE id = $1`, presetID, ).Scan(&createdBy, &scope) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"}) return } if scope != models.PresetScopePersonal || createdBy != userID { c.JSON(http.StatusForbidden, gin.H{"error": "can only edit your own personal presets"}) return } var req updatePresetRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Build dynamic SET clause sets := []string{} args := []interface{}{} argN := 1 addField := func(col string, val interface{}) { sets = append(sets, col+" = $"+itoa(argN)) args = append(args, val) argN++ } if req.Name != nil { addField("name", strings.TrimSpace(*req.Name)) } if req.Description != nil { addField("description", *req.Description) } if req.BaseModelID != nil { addField("base_model_id", *req.BaseModelID) } if req.APIConfigID != nil { addField("api_config_id", *req.APIConfigID) } if req.SystemPrompt != nil { addField("system_prompt", *req.SystemPrompt) } if req.Temperature != nil { addField("temperature", *req.Temperature) } if req.MaxTokens != nil { addField("max_tokens", *req.MaxTokens) } if req.Icon != nil { addField("icon", *req.Icon) } if req.IsShared != nil { addField("is_shared", *req.IsShared) } if req.IsActive != nil { addField("is_active", *req.IsActive) } if len(sets) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } sets = append(sets, "updated_at = NOW()") args = append(args, presetID) query := "UPDATE model_presets SET " + strings.Join(sets, ", ") + " WHERE id = $" + itoa(argN) _, err = database.DB.Exec(query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update preset"}) return } c.JSON(http.StatusOK, gin.H{"status": "updated"}) } // DeleteUserPreset deletes a personal preset owned by the current user. // DELETE /api/v1/presets/:id func (h *PresetHandler) DeleteUserPreset(c *gin.Context) { userID := getUserID(c) presetID := c.Param("id") result, err := database.DB.Exec(` DELETE FROM model_presets WHERE id = $1 AND created_by = $2 AND scope = 'personal' `, presetID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preset"}) return } rows, _ := result.RowsAffected() if rows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "preset not found or not yours"}) return } c.JSON(http.StatusOK, gin.H{"status": "deleted"}) } // ── Admin Preset Endpoints ───────────────── // ListAdminPresets returns all presets (any scope) for admin management. // GET /api/v1/admin/presets func (h *PresetHandler) ListAdminPresets(c *gin.Context) { rows, err := database.DB.Query(` SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id, mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled, mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active, mp.icon, mp.avatar, mp.created_at, mp.updated_at, COALESCE(ac.name, '') as provider_name, COALESCE(u.username, '') as creator_name, COALESCE(t.name, '') as team_name FROM model_presets mp LEFT JOIN api_configs ac ON mp.api_config_id = ac.id LEFT JOIN users u ON mp.created_by = u.id LEFT JOIN teams t ON mp.team_id = t.id ORDER BY mp.scope ASC, mp.name ASC `) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list presets"}) return } defer rows.Close() type adminPreset struct { presetResponse CreatorName string `json:"creator_name"` TeamName string `json:"team_name"` } presets := make([]adminPreset, 0) for rows.Next() { var p adminPreset if err := rows.Scan( &p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID, &p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled, &p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive, &p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName, &p.CreatorName, &p.TeamName, ); err != nil { continue } presets = append(presets, p) } c.JSON(http.StatusOK, gin.H{"presets": presets}) } // CreateAdminPreset creates a global preset (admin-managed, visible to all users). // POST /api/v1/admin/presets func (h *PresetHandler) CreateAdminPreset(c *gin.Context) { userID := getUserID(c) var req createPresetRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } // Validate api_config_id is a global config if req.APIConfigID != nil && *req.APIConfigID != "" { var count int err := database.DB.QueryRow(` SELECT COUNT(*) FROM api_configs WHERE id = $1 AND is_global = true AND is_active = true AND team_id IS NULL `, *req.APIConfigID).Scan(&count) if err != nil || count == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "global presets must use a global API config"}) return } } toolsJSON := req.ToolsEnabled if toolsJSON == "" { toolsJSON = "[]" } var id string err := database.DB.QueryRow(` INSERT INTO model_presets (name, description, base_model_id, api_config_id, system_prompt, temperature, max_tokens, tools_enabled, scope, created_by, is_shared, icon) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'global', $9, true, $10) RETURNING id `, req.Name, req.Description, req.BaseModelID, req.APIConfigID, req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON, userID, req.Icon, ).Scan(&id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create preset: " + err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"id": id}) AuditLog(c, "preset.create", "preset", id, map[string]interface{}{ "name": req.Name, "scope": "global", "base_model": req.BaseModelID, }) } // UpdateAdminPreset updates any preset (admin can edit global and personal). // PUT /api/v1/admin/presets/:id func (h *PresetHandler) UpdateAdminPreset(c *gin.Context) { presetID := c.Param("id") var req updatePresetRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } sets := []string{} args := []interface{}{} argN := 1 addField := func(col string, val interface{}) { sets = append(sets, col+" = $"+itoa(argN)) args = append(args, val) argN++ } if req.Name != nil { addField("name", strings.TrimSpace(*req.Name)) } if req.Description != nil { addField("description", *req.Description) } if req.BaseModelID != nil { addField("base_model_id", *req.BaseModelID) } if req.APIConfigID != nil { addField("api_config_id", *req.APIConfigID) } if req.SystemPrompt != nil { addField("system_prompt", *req.SystemPrompt) } if req.Temperature != nil { addField("temperature", *req.Temperature) } if req.MaxTokens != nil { addField("max_tokens", *req.MaxTokens) } if req.Icon != nil { addField("icon", *req.Icon) } if req.IsShared != nil { addField("is_shared", *req.IsShared) } if req.IsActive != nil { addField("is_active", *req.IsActive) } if len(sets) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } sets = append(sets, "updated_at = NOW()") args = append(args, presetID) query := "UPDATE model_presets SET " + strings.Join(sets, ", ") + " WHERE id = $" + itoa(argN) result, err := database.DB.Exec(query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update preset"}) return } rows, _ := result.RowsAffected() if rows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"}) return } c.JSON(http.StatusOK, gin.H{"status": "updated"}) } // DeleteAdminPreset deletes any preset. // DELETE /api/v1/admin/presets/:id func (h *PresetHandler) DeleteAdminPreset(c *gin.Context) { presetID := c.Param("id") result, err := database.DB.Exec(`DELETE FROM model_presets WHERE id = $1`, presetID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preset"}) return } rows, _ := result.RowsAffected() if rows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"}) return } c.JSON(http.StatusOK, gin.H{"status": "deleted"}) } // ── Helpers ──────────────────────────────── // isUserProvidersEnabled checks the global setting. func isUserProvidersEnabled() bool { var val string err := database.DB.QueryRow( `SELECT value FROM global_settings WHERE key = 'user_providers_enabled'`, ).Scan(&val) if err != nil { return true // default: enabled } return val == "true" } // itoa is a minimal int-to-string for building SQL arg placeholders. func itoa(n int) string { if n < 10 { return string(rune('0' + n)) } return itoa(n/10) + string(rune('0'+n%10)) } // ── Preset Resolution for Completion ─────── // ResolvePreset loads a preset by ID and returns its config overrides. // Returns nil if preset not found or inactive. func ResolvePreset(presetID, userID string) *models.ModelPreset { var p models.ModelPreset err := database.DB.QueryRow(` SELECT id, name, base_model_id, api_config_id, system_prompt, temperature, max_tokens, scope, created_by, is_active FROM model_presets WHERE id = $1 AND is_active = true AND ( scope = 'global' OR (scope = 'personal' AND created_by = $2) OR (scope = 'personal' AND is_shared = true) OR (scope = 'team' AND team_id IN ( SELECT team_id FROM team_members WHERE user_id = $2 )) ) `, presetID, userID).Scan( &p.ID, &p.Name, &p.BaseModelID, &p.APIConfigID, &p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.Scope, &p.CreatedBy, &p.IsActive, ) if err != nil { return nil } return &p } // ── Team Preset Endpoints ───────────────── // Team admins can create/manage presets scoped to their team. type createTeamPresetRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` BaseModelID string `json:"base_model_id" binding:"required"` APIConfigID *string `json:"api_config_id,omitempty"` SystemPrompt string `json:"system_prompt"` Temperature *float64 `json:"temperature,omitempty"` MaxTokens *int `json:"max_tokens,omitempty"` Icon string `json:"icon,omitempty"` } // ListTeamPresets returns presets scoped to a team. // GET /api/v1/teams/:teamId/presets func (h *PresetHandler) ListTeamPresets(c *gin.Context) { teamID := getTeamID(c) rows, err := database.DB.Query(` SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id, mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled, mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active, mp.icon, mp.avatar, mp.created_at, mp.updated_at, COALESCE(ac.name, '') as provider_name FROM model_presets mp LEFT JOIN api_configs ac ON mp.api_config_id = ac.id WHERE mp.team_id = $1 AND mp.scope = 'team' ORDER BY mp.name ASC `, teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team presets"}) return } defer rows.Close() presets := make([]presetResponse, 0) for rows.Next() { var p presetResponse if err := rows.Scan( &p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID, &p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled, &p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive, &p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName, ); err != nil { continue } presets = append(presets, p) } c.JSON(http.StatusOK, gin.H{"presets": presets}) } // CreateTeamPreset creates a preset scoped to a team. // POST /api/v1/teams/:teamId/presets func (h *PresetHandler) CreateTeamPreset(c *gin.Context) { teamID := getTeamID(c) userID := getUserID(c) var req createTeamPresetRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } toolsJSON := "[]" var id string err := database.DB.QueryRow(` INSERT INTO model_presets (name, description, base_model_id, api_config_id, system_prompt, temperature, max_tokens, tools_enabled, scope, team_id, created_by, is_shared, icon) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'team', $9, $10, true, $11) RETURNING id `, req.Name, req.Description, req.BaseModelID, req.APIConfigID, req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON, teamID, userID, req.Icon, ).Scan(&id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team preset: " + err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"id": id}) AuditLog(c, "preset.create", "preset", id, map[string]interface{}{ "name": req.Name, "scope": "team", "team_id": teamID, "base_model": req.BaseModelID, }) } // DeleteTeamPreset deletes a team-scoped preset. // DELETE /api/v1/teams/:teamId/presets/:id func (h *PresetHandler) DeleteTeamPreset(c *gin.Context) { teamID := getTeamID(c) presetID := c.Param("id") res, err := database.DB.Exec(` DELETE FROM model_presets WHERE id = $1 AND team_id = $2 AND scope = 'team' `, presetID, teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) return } if n, _ := res.RowsAffected(); n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) AuditLog(c, "preset.delete", "preset", presetID, map[string]interface{}{ "scope": "team", "team_id": teamID, }) }