package sqlite import ( "context" "database/sql" "encoding/json" "fmt" "strings" "time" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) type ChannelStore struct{} func NewChannelStore() *ChannelStore { return &ChannelStore{} } func (s *ChannelStore) Create(ctx context.Context, ch *models.Channel) error { ch.ID = store.NewID() now := time.Now().UTC() ch.CreatedAt = now ch.UpdatedAt = now _, err := DB.ExecContext(ctx, ` INSERT INTO channels (id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ch.ID, ch.UserID, ch.Title, ch.Description, ch.Type, ch.Model, ch.SystemPrompt, models.NullString(ch.ProviderConfigID), ch.IsArchived, ch.IsPinned, models.NullString(ch.FolderID), models.NullString(ch.TeamID), ToJSON(ch.Settings), now.Format(timeFmt), now.Format(timeFmt), ) return err } func (s *ChannelStore) GetByID(ctx context.Context, id string) (*models.Channel, error) { var ch models.Channel var providerConfigID, folderID, teamID sql.NullString var desc sql.NullString var settingsJSON []byte err := DB.QueryRowContext(ctx, ` SELECT id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at FROM channels WHERE id = ?`, id).Scan( &ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, &ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned, &folderID, &teamID, &settingsJSON, st(&ch.CreatedAt), st(&ch.UpdatedAt), ) if err != nil { return nil, err } ch.Description = NullableString(desc) ch.ProviderConfigID = NullableStringPtr(providerConfigID) ch.FolderID = NullableStringPtr(folderID) ch.TeamID = NullableStringPtr(teamID) json.Unmarshal(settingsJSON, &ch.Settings) return &ch, nil } func (s *ChannelStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { b := NewUpdate("channels") for k, v := range fields { if k == "settings" || k == "tags" { b.SetJSON(k, v) } else { b.Set(k, v) } } if !b.HasSets() { return nil } b.Where("id", id) _, err := b.Exec(DB) return err } func (s *ChannelStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, "DELETE FROM channels WHERE id = ?", id) return err } func (s *ChannelStore) ListForUser(ctx context.Context, userID string, opts store.ListOptions) ([]models.Channel, int, error) { // Count var total int DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels WHERE user_id = ?", userID).Scan(&total) b := NewSelect( "id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at", "channels", ).Where("user_id = ?", userID) if opts.Sort == "" { b.OrderBy("updated_at", "DESC") } b.Paginate(opts) q, args := b.Build() rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, 0, err } defer rows.Close() var result []models.Channel for rows.Next() { var ch models.Channel var providerConfigID, folderID, teamID, desc sql.NullString var settingsJSON []byte err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, &ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned, &folderID, &teamID, &settingsJSON, st(&ch.CreatedAt), st(&ch.UpdatedAt)) if err != nil { return nil, 0, err } ch.Description = NullableString(desc) ch.ProviderConfigID = NullableStringPtr(providerConfigID) ch.FolderID = NullableStringPtr(folderID) ch.TeamID = NullableStringPtr(teamID) json.Unmarshal(settingsJSON, &ch.Settings) result = append(result, ch) } return result, total, rows.Err() } func (s *ChannelStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Channel, int, error) { // Simple title search for now — will add full-text when search feature lands b := NewSelect( "id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at", "channels", ).Where("user_id = ?", userID).Where("title LIKE ?", "%"+query+"%") b.OrderBy("updated_at", "DESC") b.Paginate(opts) var total int DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels WHERE user_id = ? AND title LIKE ?", userID, "%"+query+"%").Scan(&total) q, args := b.Build() rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, 0, err } defer rows.Close() var result []models.Channel for rows.Next() { var ch models.Channel var providerConfigID, folderID, teamID, desc sql.NullString var settingsJSON []byte err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, &ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned, &folderID, &teamID, &settingsJSON, st(&ch.CreatedAt), st(&ch.UpdatedAt)) if err != nil { return nil, 0, err } ch.Description = NullableString(desc) ch.ProviderConfigID = NullableStringPtr(providerConfigID) ch.FolderID = NullableStringPtr(folderID) ch.TeamID = NullableStringPtr(teamID) json.Unmarshal(settingsJSON, &ch.Settings) result = append(result, ch) } return result, total, rows.Err() } func (s *ChannelStore) GetCursor(ctx context.Context, channelID, userID string) (*models.ChannelCursor, error) { var c models.ChannelCursor var leafID sql.NullString err := DB.QueryRowContext(ctx, ` SELECT id, channel_id, user_id, active_leaf_id, updated_at FROM channel_cursors WHERE channel_id = ? AND user_id = ?`, channelID, userID).Scan(&c.ID, &c.ChannelID, &c.UserID, &leafID, st(&c.UpdatedAt)) if err != nil { return nil, err } c.ActiveLeafID = NullableStringPtr(leafID) return &c, nil } func (s *ChannelStore) SetCursor(ctx context.Context, channelID, userID, leafID string) error { _, err := DB.ExecContext(ctx, ` INSERT INTO channel_cursors (id, channel_id, user_id, active_leaf_id) VALUES (?, ?, ?, ?) ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = excluded.active_leaf_id, updated_at = datetime('now')`, store.NewID(), channelID, userID, leafID) return err } func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error { // For persona entries, use the persona-aware path if cm.PersonaID != nil && *cm.PersonaID != "" { return s.SetPersonaModel(ctx, cm) } // Raw model upsert (no persona) — matches idx_channel_models_raw partial index _, err := DB.ExecContext(ctx, ` INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`, store.NewID(), cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault) return err } // SetPersonaModel inserts or updates a persona's channel model roster entry. func (s *ChannelStore) SetPersonaModel(ctx context.Context, cm *models.ChannelModel) error { _, err := DB.ExecContext(ctx, ` INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, persona_id, display_name, system_prompt, settings, is_default) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (channel_id, persona_id) WHERE persona_id IS NOT NULL DO UPDATE SET model_id = excluded.model_id, provider_config_id = excluded.provider_config_id, display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`, store.NewID(), cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.PersonaID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault) return err } func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) { rows, err := DB.QueryContext(ctx, ` SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''), COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''), COALESCE(cm.system_prompt, ''), cm.is_default FROM channel_models cm LEFT JOIN personas p ON p.id = cm.persona_id WHERE cm.channel_id = ?`, channelID) if err != nil { return nil, err } defer rows.Close() var result []models.ChannelModel for rows.Next() { var cm models.ChannelModel var personaID string if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID, &personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil { return nil, err } if personaID != "" { cm.PersonaID = &personaID } result = append(result, cm) } return result, rows.Err() } func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) { var cm models.ChannelModel var personaID string err := DB.QueryRowContext(ctx, ` SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''), COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''), COALESCE(cm.system_prompt, ''), cm.is_default FROM channel_models cm LEFT JOIN personas p ON p.id = cm.persona_id WHERE cm.id = ?`, id).Scan( &cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID, &personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault) if err != nil { return nil, err } if personaID != "" { cm.PersonaID = &personaID } return &cm, nil } func (s *ChannelStore) UpdateModel(ctx context.Context, id string, fields map[string]interface{}) error { if len(fields) == 0 { return nil } sets := make([]string, 0, len(fields)) args := make([]interface{}, 0, len(fields)+1) for col, val := range fields { sets = append(sets, col+" = ?") args = append(args, val) } args = append(args, id) query := "UPDATE channel_models SET " + strings.Join(sets, ", ") + " WHERE id = ?" _, err := DB.ExecContext(ctx, query, args...) return err } func (s *ChannelStore) DeleteModel(ctx context.Context, id string) error { res, err := DB.ExecContext(ctx, `DELETE FROM channel_models WHERE id = ?`, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return sql.ErrNoRows } return nil } func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) (bool, error) { var exists bool err := DB.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM channels WHERE id = ? AND user_id = ?)", channelID, userID).Scan(&exists) return exists, err } // ResolveWorkspaceID returns the effective workspace for a channel. // Resolution: channel.workspace_id > project.workspace_id. // Returns empty string if no workspace is bound. func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string) (string, error) { var wsID sql.NullString err := DB.QueryRowContext(ctx, ` SELECT COALESCE(c.workspace_id, p.workspace_id) FROM channels c LEFT JOIN project_channels pc ON pc.channel_id = c.id LEFT JOIN projects p ON p.id = pc.project_id WHERE c.id = ? LIMIT 1`, channelID).Scan(&wsID) if err != nil { if err == sql.ErrNoRows { return "", nil } return "", err } return NullableString(wsID), nil } // ── Channel Participants (ICD §3.7) ────────── func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelParticipant) error { _, err := DB.ExecContext(ctx, ` INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role, display_name, avatar_url) VALUES (?, ?, ?, ?, ?, ?, ?)`, store.NewID(), p.ChannelID, p.ParticipantType, p.ParticipantID, p.Role, p.DisplayName, p.AvatarURL) return err } func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) { rows, err := DB.QueryContext(ctx, ` SELECT id, channel_id, participant_type, participant_id, role, display_name, avatar_url, joined_at FROM channel_participants WHERE channel_id = ? ORDER BY joined_at`, channelID) if err != nil { return nil, err } defer rows.Close() var result []models.ChannelParticipant for rows.Next() { var p models.ChannelParticipant var joinedStr string if err := rows.Scan(&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID, &p.Role, &p.DisplayName, &p.AvatarURL, &joinedStr); err != nil { return nil, err } p.JoinedAt, _ = time.Parse("2006-01-02 15:04:05", joinedStr) result = append(result, p) } return result, rows.Err() } func (s *ChannelStore) GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error) { var p models.ChannelParticipant var joinedStr string err := DB.QueryRowContext(ctx, ` SELECT id, channel_id, participant_type, participant_id, role, display_name, avatar_url, joined_at FROM channel_participants WHERE id = ?`, id).Scan( &p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID, &p.Role, &p.DisplayName, &p.AvatarURL, &joinedStr) if err != nil { return nil, err } p.JoinedAt, _ = time.Parse("2006-01-02 15:04:05", joinedStr) return &p, nil } func (s *ChannelStore) UpdateParticipantRole(ctx context.Context, id, role string) error { _, err := DB.ExecContext(ctx, `UPDATE channel_participants SET role = ? WHERE id = ?`, role, id) return err } func (s *ChannelStore) RemoveParticipant(ctx context.Context, id string) error { res, err := DB.ExecContext(ctx, `DELETE FROM channel_participants WHERE id = ?`, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return sql.ErrNoRows } return nil } func (s *ChannelStore) IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error) { var count int err := DB.QueryRowContext(ctx, ` SELECT COUNT(*) FROM channel_participants WHERE channel_id = ? AND participant_type = ? AND participant_id = ?`, channelID, pType, pID).Scan(&count) return count > 0, err } func (s *ChannelStore) GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error) { var role string err := DB.QueryRowContext(ctx, ` SELECT role FROM channel_participants WHERE channel_id = ? AND participant_type = ? AND participant_id = ?`, channelID, pType, pID).Scan(&role) return role, err } func (s *ChannelStore) CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error) { var count int err := DB.QueryRowContext(ctx, ` SELECT COUNT(*) FROM channel_participants WHERE channel_id = ? AND role = ?`, channelID, role).Scan(&count) return count, err } func (s *ChannelStore) DeleteModelByPersona(ctx context.Context, channelID, personaID string) error { _, err := DB.ExecContext(ctx, `DELETE FROM channel_models WHERE channel_id = ? AND persona_id = ?`, channelID, personaID) return err } // ── Admin: Archived Channel Management ────── func (s *ChannelStore) ListArchived(ctx context.Context, opts store.ListOptions) ([]store.ArchivedChannel, int, error) { // Count var total int DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM channels WHERE is_archived = 1`).Scan(&total) rows, err := DB.QueryContext(ctx, ` SELECT c.id, c.title, c.type, COALESCE(u.username, '') AS owner_name, c.updated_at, (SELECT COUNT(*) FROM messages WHERE channel_id = c.id) AS message_count FROM channels c LEFT JOIN users u ON u.id = c.user_id WHERE c.is_archived = 1 ORDER BY c.updated_at DESC LIMIT ? OFFSET ?`, opts.Limit, opts.Offset) if err != nil { return nil, 0, err } defer rows.Close() var channels []store.ArchivedChannel for rows.Next() { var ch store.ArchivedChannel if rows.Scan(&ch.ID, &ch.Title, &ch.Type, &ch.OwnerName, &ch.UpdatedAt, &ch.MessageCount) == nil { channels = append(channels, ch) } } if channels == nil { channels = []store.ArchivedChannel{} } return channels, total, rows.Err() } func (s *ChannelStore) Purge(ctx context.Context, id string) error { // Verify the channel is actually archived var isArchived int err := DB.QueryRowContext(ctx, `SELECT is_archived FROM channels WHERE id = ?`, id).Scan(&isArchived) if err != nil { return fmt.Errorf("channel not found") } if isArchived == 0 { return fmt.Errorf("channel must be archived before purging") } // Hard delete (CASCADE removes messages, participants, models) _, err = DB.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, id) return err }