Issue 9 chat api (#25)
This commit is contained in:
380
server/handlers/chats.go
Normal file
380
server/handlers/chats.go
Normal file
@@ -0,0 +1,380 @@
|
||||
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"})
|
||||
}
|
||||
Reference in New Issue
Block a user