package handlers import ( "database/sql" "math" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/lib/pq" "git.gobha.me/xcaliber/chat-switchboard/database" ) // ── Request / Response types ──────────────── type createChatRequest struct { Title string `json:"title" binding:"required,max=500"` 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 updateChatRequest struct { Title *string `json:"title,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 chatResponse struct { ID string `json:"id"` UserID string `json:"user_id"` Title string `json:"title"` 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"` } // ChatHandler holds dependencies for chat endpoints. type ChatHandler struct{} // NewChatHandler creates a new chat handler. func NewChatHandler() *ChatHandler { return &ChatHandler{} } // ── 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 Chats ────────────────────────────── func (h *ChatHandler) ListChats(c *gin.Context) { userID := getUserID(c) page, perPage, offset := parsePagination(c) // Optional filters archived := c.DefaultQuery("archived", "false") folder := c.Query("folder") // Count total var total int countQuery := `SELECT COUNT(*) FROM chats WHERE user_id = $1 AND is_archived = $2` countArgs := []interface{}{userID, archived == "true"} if folder != "" { countQuery += ` AND folder = $3` countArgs = append(countArgs, folder) } if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count chats"}) return } // Fetch chats with message count query := ` SELECT c.id, c.user_id, c.title, 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 chats c LEFT JOIN ( SELECT chat_id, COUNT(*) AS cnt FROM chat_messages GROUP BY chat_id ) mc ON mc.chat_id = c.id WHERE c.user_id = $1 AND c.is_archived = $2` args := []interface{}{userID, archived == "true"} argN := 3 if folder != "" { query += ` AND c.folder = $` + strconv.Itoa(argN) args = append(args, folder) 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 chats"}) return } defer rows.Close() chats := make([]chatResponse, 0) for rows.Next() { var chat chatResponse var tags []string err := rows.Scan( &chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID, &chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder, pq.Array(&tags), &chat.MessageCount, &chat.CreatedAt, &chat.UpdatedAt, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan chat"}) return } if tags == nil { tags = []string{} } chat.Tags = tags chats = append(chats, chat) } c.JSON(http.StatusOK, paginatedResponse{ Data: chats, Page: page, PerPage: perPage, Total: total, TotalPages: int(math.Ceil(float64(total) / float64(perPage))), }) } // ── Create Chat ───────────────────────────── func (h *ChatHandler) CreateChat(c *gin.Context) { userID := getUserID(c) var req createChatRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Tags == nil { req.Tags = []string{} } var chat chatResponse var tags []string err := database.DB.QueryRow(` INSERT INTO chats (user_id, title, model, system_prompt, api_config_id, folder, tags) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, user_id, title, model, api_config_id, system_prompt, is_archived, is_pinned, folder, tags, created_at, updated_at `, userID, req.Title, req.Model, req.SystemPrompt, req.APIConfigID, req.Folder, pq.Array(req.Tags), ).Scan( &chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID, &chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder, pq.Array(&tags), &chat.CreatedAt, &chat.UpdatedAt, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create chat"}) return } if tags == nil { tags = []string{} } chat.Tags = tags chat.MessageCount = 0 c.JSON(http.StatusCreated, chat) } // ── Get Chat ──────────────────────────────── func (h *ChatHandler) GetChat(c *gin.Context) { userID := getUserID(c) chatID := c.Param("id") var chat chatResponse var tags []string err := database.DB.QueryRow(` SELECT c.id, c.user_id, c.title, 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 chats c LEFT JOIN ( SELECT chat_id, COUNT(*) AS cnt FROM chat_messages GROUP BY chat_id ) mc ON mc.chat_id = c.id WHERE c.id = $1 AND c.user_id = $2 `, chatID, userID).Scan( &chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID, &chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder, pq.Array(&tags), &chat.MessageCount, &chat.CreatedAt, &chat.UpdatedAt, ) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get chat"}) return } if tags == nil { tags = []string{} } chat.Tags = tags c.JSON(http.StatusOK, chat) } // ── Update Chat ───────────────────────────── func (h *ChatHandler) UpdateChat(c *gin.Context) { userID := getUserID(c) chatID := c.Param("id") if database.DB == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"}) return } var req updateChatRequest 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 chats WHERE id = $1`, chatID).Scan(&ownerID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) return } if ownerID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "not your chat"}) 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.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 chats 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, chatID, userID) _, err = database.DB.Exec(query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update chat"}) return } // Return updated chat h.GetChat(c) } // ── Delete Chat ───────────────────────────── func (h *ChatHandler) DeleteChat(c *gin.Context) { userID := getUserID(c) chatID := c.Param("id") result, err := database.DB.Exec( `DELETE FROM chats WHERE id = $1 AND user_id = $2`, chatID, userID, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete chat"}) return } rows, _ := result.RowsAffected() if rows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "chat deleted"}) }