package handlers import ( "database/sql" "encoding/json" "math" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/lib/pq" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Request / Response types ──────────────── type createChannelRequest struct { Title string `json:"title" binding:"required,max=500"` Type string `json:"type,omitempty"` // direct (default), dm, group, channel, workflow Description string `json:"description,omitempty"` Model string `json:"model,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` ProviderConfigID *string `json:"provider_config_id,omitempty"` Folder string `json:"folder,omitempty"` Tags []string `json:"tags,omitempty"` // v0.23.2: DM creation Participants []string `json:"participants,omitempty"` // user IDs for DM (exactly 1 other user) AiMode string `json:"ai_mode,omitempty"` // auto (default), mention_only, off } type updateChannelRequest struct { Title *string `json:"title,omitempty"` Description *string `json:"description,omitempty"` Model *string `json:"model,omitempty"` SystemPrompt *string `json:"system_prompt,omitempty"` ProviderConfigID *string `json:"provider_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"` Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5) AiMode *string `json:"ai_mode,omitempty"` // v0.23.2: auto, mention_only, off Topic *string `json:"topic,omitempty"` // v0.23.2: channel topic } type channelResponse struct { ID string `json:"id"` UserID string `json:"user_id"` Title string `json:"title"` Type string `json:"type"` AiMode string `json:"ai_mode,omitempty"` Topic *string `json:"topic,omitempty"` Description *string `json:"description"` Model *string `json:"model"` ProviderConfigID *string `json:"provider_config_id"` SystemPrompt *string `json:"system_prompt"` IsArchived bool `json:"is_archived"` IsPinned bool `json:"is_pinned"` Folder *string `json:"folder"` ProjectID *string `json:"project_id,omitempty"` WorkspaceID *string `json:"workspace_id,omitempty"` Tags []string `json:"tags"` Settings json.RawMessage `json:"settings,omitempty"` MessageCount int `json:"message_count"` UnreadCount int `json:"unread_count,omitempty"` 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{} } // channelDeleteHook is called after a channel is successfully deleted. // Set at startup by SetChannelDeleteHook to clean up storage files. var channelDeleteHook func(channelID string) // SetChannelDeleteHook registers a callback invoked after channel deletion. // Used to clean up channel files on the storage backend. func SetChannelDeleteHook(fn func(channelID string)) { channelDeleteHook = fn } // ── Tag Helpers ───────────────────────────── // writeTagsArg returns a value suitable for inserting/updating the tags column. // Postgres: pq.Array SQLite: JSON string func writeTagsArg(tags []string) interface{} { if database.IsSQLite() { b, _ := json.Marshal(tags) return string(b) } return pq.Array(tags) } // scanJSON, scanTags, SafeJSON → safe_json.go // ── 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 // v0.23.1: multi-value type filter. Supports both ?types=dm&types=channel // and comma-joined ?types=dm,channel var channelTypes []string if raw := c.Query("types"); raw != "" { for _, t := range strings.Split(raw, ",") { t = strings.TrimSpace(t) if t != "" { channelTypes = append(channelTypes, t) } } } search := strings.TrimSpace(c.Query("search")) projectFilter := c.Query("project_id") // "uuid" or "none" // Count total — include channels owned by user OR where user is a participant countQuery := `SELECT COUNT(*) FROM channels c WHERE (c.user_id = $1 OR c.id IN ( SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1 )) AND c.is_archived = $2` countArgs := []interface{}{userID, archived == "true"} argN := 3 if len(channelTypes) > 0 { placeholders := make([]string, len(channelTypes)) for i, t := range channelTypes { placeholders[i] = "$" + strconv.Itoa(argN) countArgs = append(countArgs, strings.TrimSpace(t)) argN++ } countQuery += " AND c.type IN (" + strings.Join(placeholders, ",") + ")" } else if channelType != "" { countQuery += ` AND c.type = $` + strconv.Itoa(argN) countArgs = append(countArgs, channelType) argN++ } if folder != "" { countQuery += ` AND c.folder = $` + strconv.Itoa(argN) countArgs = append(countArgs, folder) argN++ } if search != "" { countQuery += ` AND c.title ILIKE $` + strconv.Itoa(argN) countArgs = append(countArgs, "%"+search+"%") argN++ } if projectFilter == "none" { countQuery += ` AND c.project_id IS NULL` } else if projectFilter != "" { countQuery += ` AND c.project_id = $` + strconv.Itoa(argN) countArgs = append(countArgs, projectFilter) argN++ } var total int if err := database.DB.QueryRow(database.Q(countQuery), countArgs...).Scan(&total); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"}) return } // Fetch channels with message count // Include channels owned by user OR where user is a participant (multi-user) query := ` SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic, c.description, c.model, c.provider_config_id, c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id, c.tags, c.settings, 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 OR c.id IN ( SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1 )) AND c.is_archived = $2` args := []interface{}{userID, archived == "true"} argN = 3 if len(channelTypes) > 0 { placeholders := make([]string, len(channelTypes)) for i, t := range channelTypes { placeholders[i] = "$" + strconv.Itoa(argN) args = append(args, strings.TrimSpace(t)) argN++ } query += " AND c.type IN (" + strings.Join(placeholders, ",") + ")" } else 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++ } if projectFilter == "none" { query += ` AND c.project_id IS NULL` } else if projectFilter != "" { query += ` AND c.project_id = $` + strconv.Itoa(argN) args = append(args, projectFilter) 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(database.Q(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.AiMode, &ch.Topic, &ch.Description, &ch.Model, &ch.ProviderConfigID, &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID, scanTags(&tags), scanJSON(&ch.Settings), &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) } rows.Close() // v0.23.2: Compute unread counts per channel (safe post-processing) for i := range channels { _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` SELECT COUNT(*) FROM messages m JOIN channel_participants cp ON cp.channel_id = m.channel_id WHERE cp.channel_id = $1 AND cp.participant_type = 'user' AND cp.participant_id = $2 AND m.created_at > cp.last_read_at `), channels[i].ID, userID).Scan(&channels[i].UnreadCount) } SafeJSON(c, 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" } // Default ai_mode: mention_only for DMs, auto for everything else aiMode := req.AiMode if aiMode == "" { if channelType == "dm" { aiMode = "mention_only" } else { aiMode = "auto" } } // ── DM dedup: check for existing DM between these two users ── if channelType == "dm" && len(req.Participants) == 1 { otherUserID := req.Participants[0] if otherUserID == userID { c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create DM with yourself"}) return } // Look for an existing DM channel where both users are participants var existingID string _ = database.DB.QueryRow(database.Q(` SELECT cp1.channel_id FROM channel_participants cp1 JOIN channel_participants cp2 ON cp1.channel_id = cp2.channel_id JOIN channels c ON c.id = cp1.channel_id WHERE c.type = 'dm' AND cp1.participant_type = 'user' AND cp1.participant_id = $1 AND cp2.participant_type = 'user' AND cp2.participant_id = $2 LIMIT 1 `), userID, otherUserID).Scan(&existingID) if existingID != "" { // Return existing channel instead of creating duplicate var ch channelResponse var tags []string err := database.DB.QueryRow(database.Q(` SELECT id, user_id, title, type, description, model, provider_config_id, system_prompt, is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at FROM channels WHERE id = $1 `), existingID).Scan( &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID, &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID, scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt, ) if err == nil { if tags == nil { tags = []string{} } ch.Tags = tags SafeJSON(c, http.StatusOK, ch) // 200, not 201 — existing resource return } // If read fails, fall through and create new (shouldn't happen) } } // INSERT and retrieve the new row var ch channelResponse var tags []string if database.IsSQLite() { id := store.NewID() _, err := database.DB.Exec(` INSERT INTO channels (id, user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags, ai_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags), aiMode, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"}) return } // Read back the row err = database.DB.QueryRow(` SELECT id, user_id, title, type, description, model, provider_config_id, system_prompt, is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at FROM channels WHERE id = ?`, id).Scan( &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID, &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID, scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created channel: " + err.Error()}) return } } else { err := database.DB.QueryRow(` INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags, ai_mode) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt, is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at `, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID, req.Folder, pq.Array(req.Tags), aiMode, ).Scan( &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID, &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID, pq.Array(&tags), scanJSON(&ch.Settings), &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_participant for the creator if database.IsSQLite() { _, _ = database.DB.Exec(` INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role) VALUES (?, ?, 'user', ?, 'owner') ON CONFLICT DO NOTHING `, store.NewID(), ch.ID, userID) } else { _, _ = database.DB.Exec(` INSERT INTO channel_participants (channel_id, participant_type, participant_id, role) VALUES ($1, 'user', $2, 'owner') ON CONFLICT DO NOTHING `, ch.ID, userID) } // v0.23.2: Add DM partner as member participant if channelType == "dm" && len(req.Participants) > 0 { for _, pid := range req.Participants { if pid == userID { continue // skip self (already added as owner) } if database.IsSQLite() { _, _ = database.DB.Exec(` INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role) VALUES (?, ?, 'user', ?, 'member') ON CONFLICT DO NOTHING `, store.NewID(), ch.ID, pid) } else { _, _ = database.DB.Exec(` INSERT INTO channel_participants (channel_id, participant_type, participant_id, role) VALUES ($1, 'user', $2, 'member') ON CONFLICT DO NOTHING `, ch.ID, pid) } } } // Auto-create channel_model if model specified if req.Model != "" { if database.IsSQLite() { _, _ = database.DB.Exec(` INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, is_default) VALUES (?, ?, ?, ?, 1) ON CONFLICT DO NOTHING `, store.NewID(), ch.ID, req.Model, req.ProviderConfigID) } else { _, _ = database.DB.Exec(` INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default) VALUES ($1, $2, $3, true) ON CONFLICT DO NOTHING `, ch.ID, req.Model, req.ProviderConfigID) } } SafeJSON(c, 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(database.Q(` SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id, c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id, c.tags, c.settings, 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.ProviderConfigID, &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID, scanTags(&tags), scanJSON(&ch.Settings), &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 SafeJSON(c, 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(database.Q(`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 with ? placeholders (converted for Postgres) setClauses := []string{} args := []interface{}{} addClause := func(col string, val interface{}) { setClauses = append(setClauses, col+" = ?") args = append(args, val) } 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.ProviderConfigID != nil { addClause("provider_config_id", *req.ProviderConfigID) } 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", writeTagsArg(req.Tags)) } if req.Settings != nil { // Validate settings is well-formed JSON before writing if !json.Valid([]byte(*req.Settings)) { c.JSON(http.StatusBadRequest, gin.H{"error": "settings must be valid JSON"}) return } // JSONB merge: new settings keys overwrite existing, unmentioned keys preserved if database.IsSQLite() { setClauses = append(setClauses, "settings = json_patch(settings, ?)") } else { setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || ?::jsonb") } args = append(args, []byte(*req.Settings)) } if req.WorkspaceID != nil { if *req.WorkspaceID == "" { addClause("workspace_id", nil) // unbind } else { addClause("workspace_id", *req.WorkspaceID) } } if req.AiMode != nil { mode := *req.AiMode if mode != "auto" && mode != "mention_only" && mode != "off" { c.JSON(http.StatusBadRequest, gin.H{"error": "ai_mode must be auto, mention_only, or off"}) return } addClause("ai_mode", mode) } if req.Topic != nil { addClause("topic", *req.Topic) } if len(setClauses) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } query := "UPDATE channels SET " + strings.Join(setClauses, ", ") query += " WHERE id = ? AND user_id = ?" args = append(args, channelID, userID) if !database.IsSQLite() { query = convertPlaceholders(query) } _, 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( database.Q(`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 } // Clean up storage files (CASCADE already removed PG file rows) if channelDeleteHook != nil { go channelDeleteHook(channelID) } c.JSON(http.StatusOK, gin.H{"message": "channel deleted"}) } // ── Mark Read (v0.23.2) ─────────────────────── func (h *ChannelHandler) MarkRead(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") // Update the read cursor timestamp (last_read_at exists since migration 005) _, err := database.DB.ExecContext(c.Request.Context(), database.Q(` UPDATE channel_participants SET last_read_at = NOW() WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2 `), channelID, userID) if err != nil { // Participant row may not exist for legacy direct chats — that's OK c.JSON(http.StatusOK, gin.H{"ok": true}) return } // Best-effort: also set last_read_message_id if the column exists (migration 017) var latestMsgID *string _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1 `), channelID).Scan(&latestMsgID) if latestMsgID != nil { // This may fail if 017 hasn't been applied — that's fine, last_read_at is primary _, _ = database.DB.ExecContext(c.Request.Context(), database.Q(` UPDATE channel_participants SET last_read_message_id = $1 WHERE channel_id = $2 AND participant_type = 'user' AND participant_id = $3 `), *latestMsgID, channelID, userID) } c.JSON(http.StatusOK, gin.H{"ok": true}) }