package handlers import ( "context" "encoding/json" "fmt" "log" "math" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "chat-switchboard/database" "chat-switchboard/models" "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"` FolderID *string `json:"folder_id,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"` FolderID *string `json:"folder_id,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"` FolderID *string `json:"folder_id,omitempty"` 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 { stores store.Stores } // NewChannelHandler creates a new channel handler. func NewChannelHandler(stores store.Stores) *ChannelHandler { return &ChannelHandler{stores: stores} } // 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 } // ── 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 } // isSessionAuth returns true if the request is from a session participant (v0.24.3). func isSessionAuth(c *gin.Context) bool { return c.GetString("auth_type") == "session" } // sessionCanAccessChannel validates that a session participant is authorized // for the given channel. func sessionCanAccessChannel(c *gin.Context, channelID string) bool { if !isSessionAuth(c) { return false } return c.GetString("channel_id") == channelID } // 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 } // listItemToResponse converts a store.ChannelListItem to a handler response. func listItemToResponse(item store.ChannelListItem) channelResponse { tags := item.Tags if tags == nil { tags = []string{} } return channelResponse{ ID: item.ID, UserID: item.UserID, Title: item.Title, Type: item.Type, AiMode: item.AiMode, Topic: item.Topic, Description: item.Description, Model: item.Model, ProviderConfigID: item.ProviderConfigID, SystemPrompt: item.SystemPrompt, IsArchived: item.IsArchived, IsPinned: item.IsPinned, Folder: item.Folder, FolderID: item.FolderID, ProjectID: item.ProjectID, WorkspaceID: item.WorkspaceID, Tags: tags, Settings: item.Settings, MessageCount: item.MessageCount, UnreadCount: item.UnreadCount, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } } // ── List Channels ─────────────────────────── func (h *ChannelHandler) ListChannels(c *gin.Context) { userID := getUserID(c) page, perPage, offset := parsePagination(c) // Optional filters archived := c.DefaultQuery("archived", "false") 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) } } } if len(channelTypes) == 0 { if ct := c.DefaultQuery("type", ""); ct != "" { channelTypes = []string{ct} } } filter := store.ChannelListFilter{ ListOptions: store.ListOptions{Limit: perPage, Offset: offset}, Archived: archived == "true", Types: channelTypes, Folder: c.Query("folder"), FolderID: c.Query("folder_id"), Search: strings.TrimSpace(c.Query("search")), ProjectID: c.Query("project_id"), } items, total, err := h.stores.Channels.ListFiltered(c.Request.Context(), userID, filter) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"}) return } channels := make([]channelResponse, 0, len(items)) for _, item := range items { channels = append(channels, listItemToResponse(item)) } // Resolve DM titles: show the other participant's name, not the creator's label resolveDMTitles(c.Request.Context(), channels, userID) 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" } } ctx := c.Request.Context() // ── 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 } existingID, _ := h.stores.Channels.FindExistingDM(ctx, userID, otherUserID) if existingID != "" { // Return existing channel instead of creating duplicate item, err := h.stores.Channels.GetForUser(ctx, existingID, userID) if err == nil { SafeJSON(c, http.StatusOK, listItemToResponse(*item)) // 200, not 201 — existing resource return } // If read fails, fall through and create new (shouldn't happen) } } // Build channel model ch := &models.Channel{ UserID: userID, Title: req.Title, Type: channelType, Description: req.Description, Model: req.Model, SystemPrompt: req.SystemPrompt, ProviderConfigID: req.ProviderConfigID, FolderID: req.FolderID, } dmPartners := []string{} if channelType == "dm" { dmPartners = req.Participants } defaultConfigID := "" if req.ProviderConfigID != nil { defaultConfigID = *req.ProviderConfigID } if err := h.stores.Channels.CreateFull(ctx, ch, req.Folder, req.Tags, aiMode, userID, dmPartners, req.Model, defaultConfigID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"}) return } // Fetch full response with message count item, err := h.stores.Channels.GetForUser(ctx, ch.ID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created channel"}) return } SafeJSON(c, http.StatusCreated, listItemToResponse(*item)) } // ── Get Channel ───────────────────────────── func (h *ChannelHandler) GetChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") item, err := h.stores.Channels.GetForUser(c.Request.Context(), channelID, userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } SafeJSON(c, http.StatusOK, listItemToResponse(*item)) } // ── Update Channel ────────────────────────── func (h *ChannelHandler) UpdateChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") ctx := c.Request.Context() var req updateChannelRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Verify ownership (participants can only move to folder) owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } if !owns { // Participants may move channels to their own folders folderOnly := req.FolderID != nil && req.Title == nil && req.Description == nil && req.Model == nil && req.SystemPrompt == nil && req.ProviderConfigID == nil && req.IsArchived == nil && req.IsPinned == nil && req.Folder == nil && req.Tags == nil && req.WorkspaceID == nil && req.AiMode == nil && req.Topic == nil && req.Settings == nil if !folderOnly { c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) return } // Verify they are a participant canAccess, _ := h.stores.Channels.UserCanAccess(ctx, channelID, userID) if !canAccess { c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) return } } // Build fields map for store.Update fields := map[string]interface{}{} if req.Title != nil { fields["title"] = *req.Title } if req.Description != nil { fields["description"] = *req.Description } if req.Model != nil { fields["model"] = *req.Model } if req.SystemPrompt != nil { fields["system_prompt"] = *req.SystemPrompt } if req.ProviderConfigID != nil { if *req.ProviderConfigID == "" { fields["provider_config_id"] = nil } else { fields["provider_config_id"] = *req.ProviderConfigID } } if req.IsArchived != nil { fields["is_archived"] = *req.IsArchived } if req.IsPinned != nil { fields["is_pinned"] = *req.IsPinned } if req.Folder != nil { fields["folder"] = *req.Folder } if req.FolderID != nil { if *req.FolderID == "" { fields["folder_id"] = nil // unbind from folder } else { fields["folder_id"] = *req.FolderID } } if req.Tags != nil { fields["tags"] = req.Tags } if req.WorkspaceID != nil { if *req.WorkspaceID == "" { fields["workspace_id"] = nil // unbind } else { fields["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 } fields["ai_mode"] = mode } if req.Topic != nil { fields["topic"] = *req.Topic } hasFields := len(fields) > 0 hasSettings := req.Settings != nil if !hasFields && !hasSettings { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } if hasSettings { if !json.Valid([]byte(*req.Settings)) { c.JSON(http.StatusBadRequest, gin.H{"error": "settings must be valid JSON"}) return } } if hasFields { if err := h.stores.Channels.Update(ctx, channelID, fields); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"}) return } } if hasSettings { if err := h.stores.Channels.MergeSettings(ctx, channelID, json.RawMessage(*req.Settings)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"}) return } } // Return updated channel h.GetChannel(c) } // ── Delete Channel ────────────────────────── func (h *ChannelHandler) DeleteChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") ctx := c.Request.Context() // Check ownership first (without deleting) owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } if !owns { // Not the owner — if participant, leave the channel instead of deleting. res, _ := database.DB.ExecContext(ctx, database.Q(` DELETE FROM channel_participants WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2 `), channelID, userID) if rows, _ := res.RowsAffected(); rows > 0 { c.JSON(http.StatusOK, gin.H{"message": "left channel"}) return } c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } // Check if retention policy applies (global/team provider + TTL > 0) if h.shouldRetain(ctx, channelID) { ttl := h.retentionTTL(ctx) purgeAfter := time.Now().Add(time.Duration(ttl) * 24 * time.Hour) if err := h.stores.Channels.ArchiveForRetention(ctx, channelID, purgeAfter); err != nil { log.Printf("[retention] ArchiveForRetention(%s) error: %v", channelID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to archive channel"}) return } c.JSON(http.StatusOK, gin.H{ "message": "channel archived for retention", "purge_after": purgeAfter.UTC().Format("2006-01-02T15:04:05Z"), }) return } // Exempt — hard delete n, err := h.stores.Channels.DeleteByOwner(ctx, channelID, userID) if err != nil || n == 0 { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"}) return } if channelDeleteHook != nil { go channelDeleteHook(channelID) } c.JSON(http.StatusOK, gin.H{"message": "channel deleted"}) } // shouldRetain returns true if a channel uses a global or team provider // and the retention TTL is configured (> 0). func (h *ChannelHandler) shouldRetain(ctx context.Context, channelID string) bool { ttl := h.retentionTTL(ctx) if ttl <= 0 { return false } // Only BYOK (personal) provider channels are exempt. // All others — global, team, or no explicit provider — follow retention. var providerConfigID *string _ = database.DB.QueryRowContext(ctx, database.Q(` SELECT provider_config_id FROM channels WHERE id = $1 `), channelID).Scan(&providerConfigID) if providerConfigID == nil || *providerConfigID == "" { return true // no explicit provider → defaults to global → retain } cfg, err := h.stores.Providers.GetByID(ctx, *providerConfigID) if err != nil { return true // provider deleted (FK SET NULL already handled above) → retain } return cfg.Scope != models.ScopePersonal } // retentionTTL reads the configured retention TTL in days from global settings. func (h *ChannelHandler) retentionTTL(ctx context.Context) int { cfg, err := h.stores.GlobalConfig.Get(ctx, "retention_ttl_days") if err != nil { return 0 } if v, ok := cfg["value"].(float64); ok { return int(v) } return 0 } // ── Mark Read (v0.23.2) ─────────────────────── func (h *ChannelHandler) MarkRead(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") err := h.stores.Channels.MarkRead(c.Request.Context(), 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}) } // ── DM Title Resolution ────────────────────── // resolveDMTitles replaces DM channel titles with the other participant's // display name so each user sees "DM " instead of the // creator's original label. func resolveDMTitles(ctx context.Context, channels []channelResponse, viewerID string) { // Collect DM channel IDs var dmIDs []string dmIdx := map[string]int{} // channel_id → index in channels slice for i, ch := range channels { if ch.Type == "dm" { dmIDs = append(dmIDs, ch.ID) dmIdx[ch.ID] = i } } if len(dmIDs) == 0 { return } // Query: for each DM, get the OTHER participant's display name // Uses channel_participants to find the peer, then joins users for name placeholders := make([]string, len(dmIDs)) args := make([]interface{}, 0, len(dmIDs)+1) for i, id := range dmIDs { placeholders[i] = fmt.Sprintf("$%d", i+1) args = append(args, id) } args = append(args, viewerID) viewerPlaceholder := fmt.Sprintf("$%d", len(args)) query := database.Q(fmt.Sprintf(` SELECT cp.channel_id, COALESCE(NULLIF(u.display_name, ''), u.username, 'Unknown') FROM channel_participants cp JOIN users u ON u.id = cp.participant_id WHERE cp.channel_id IN (%s) AND cp.participant_type = 'user' AND cp.participant_id != %s LIMIT %d `, strings.Join(placeholders, ","), viewerPlaceholder, len(dmIDs))) rows, err := database.DB.QueryContext(ctx, query, args...) if err != nil { return // non-fatal: keep original titles } defer rows.Close() for rows.Next() { var chID, peerName string if rows.Scan(&chID, &peerName) == nil { if idx, ok := dmIdx[chID]; ok { channels[idx].Title = "DM " + peerName } } } }