package postgres import ( "context" "database/sql" "encoding/json" "fmt" "strings" "time" "github.com/lib/pq" "switchboard-core/models" "switchboard-core/store" ) type ChannelStore struct{} func NewChannelStore() *ChannelStore { return &ChannelStore{} } func (s *ChannelStore) Create(ctx context.Context, ch *models.Channel) error { return DB.QueryRowContext(ctx, ` INSERT INTO channels (user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id, created_at, updated_at`, 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), ).Scan(&ch.ID, &ch.CreatedAt, &ch.UpdatedAt) } 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 = $1`, id).Scan( &ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, &ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned, &folderID, &teamID, &settingsJSON, &ch.CreatedAt, &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 = $1", 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 = $1", 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, &ch.CreatedAt, &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 ILIKE ?", "%"+query+"%") b.OrderBy("updated_at", "DESC") b.Paginate(opts) var total int DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels WHERE user_id = $1 AND title ILIKE $2", 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, &ch.CreatedAt, &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 = $1 AND user_id = $2`, channelID, userID).Scan(&c.ID, &c.ChannelID, &c.UserID, &leafID, &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 (channel_id, user_id, active_leaf_id) VALUES ($1, $2, $3) ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = $3, updated_at = NOW()`, 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) } // Nullable provider_config_id: empty string → SQL NULL (UUID FK) var provCfgID interface{} = cm.ProviderConfigID if cm.ProviderConfigID == "" { provCfgID = nil } // Raw model upsert (no persona) — matches idx_channel_models_raw partial index _, err := DB.ExecContext(ctx, ` INSERT INTO channel_models (channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET display_name = $4, system_prompt = $5, settings = $6, is_default = $7`, cm.ChannelID, cm.ModelID, provCfgID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault) return err } // SetPersonaModel inserts or updates a persona's channel model roster entry. // Uses the idx_channel_models_persona partial index (one per persona per channel). func (s *ChannelStore) SetPersonaModel(ctx context.Context, cm *models.ChannelModel) error { _, err := DB.ExecContext(ctx, ` INSERT INTO channel_models (channel_id, model_id, provider_config_id, persona_id, display_name, system_prompt, settings, is_default) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (channel_id, persona_id) WHERE persona_id IS NOT NULL DO UPDATE SET model_id = $2, provider_config_id = $3, display_name = $5, system_prompt = $6, settings = $7, is_default = $8`, 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::text, ''), COALESCE(cm.persona_id::text, ''), 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 = $1`, 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::text, ''), COALESCE(cm.persona_id::text, ''), 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 = $1`, 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 } // Build SET clause dynamically sets := make([]string, 0, len(fields)) args := make([]interface{}, 0, len(fields)+1) i := 1 for col, val := range fields { sets = append(sets, col+" = $"+fmt.Sprintf("%d", i)) args = append(args, val) i++ } args = append(args, id) query := "UPDATE channel_models SET " + strings.Join(sets, ", ") + " WHERE id = $" + fmt.Sprintf("%d", i) _, 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 = $1`, 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 = $1 AND user_id = $2)", 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 = $1 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 (channel_id, participant_type, participant_id, role, display_name, avatar_url) VALUES ($1, $2, $3, $4, $5, $6)`, 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 cp.id, cp.channel_id, cp.participant_type, cp.participant_id, cp.role, COALESCE(NULLIF(cp.display_name,''), NULLIF(u.display_name,''), u.username) AS display_name, COALESCE(NULLIF(cp.avatar_url,''), u.avatar_url) AS avatar_url, cp.joined_at FROM channel_participants cp LEFT JOIN users u ON cp.participant_type = 'user' AND cp.participant_id = u.id WHERE cp.channel_id = $1 ORDER BY cp.joined_at`, channelID) if err != nil { return nil, err } defer rows.Close() var result []models.ChannelParticipant for rows.Next() { var p models.ChannelParticipant if err := rows.Scan(&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID, &p.Role, &p.DisplayName, &p.AvatarURL, &p.JoinedAt); err != nil { return nil, err } result = append(result, p) } return result, rows.Err() } func (s *ChannelStore) GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error) { var p models.ChannelParticipant err := DB.QueryRowContext(ctx, ` SELECT id, channel_id, participant_type, participant_id, role, display_name, avatar_url, joined_at FROM channel_participants WHERE id = $1`, id).Scan( &p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID, &p.Role, &p.DisplayName, &p.AvatarURL, &p.JoinedAt) if err != nil { return nil, err } return &p, nil } func (s *ChannelStore) UpdateParticipantRole(ctx context.Context, id, role string) error { _, err := DB.ExecContext(ctx, `UPDATE channel_participants SET role = $1 WHERE id = $2`, 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 = $1`, 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 exists bool err := DB.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM channel_participants WHERE channel_id = $1 AND participant_type = $2 AND participant_id = $3)`, channelID, pType, pID).Scan(&exists) return exists, 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 = $1 AND participant_type = $2 AND participant_id = $3`, 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 = $1 AND role = $2`, 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 = $1 AND persona_id = $2`, 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 = true`).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 = true ORDER BY c.updated_at DESC LIMIT $1 OFFSET $2`, 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 bool err := DB.QueryRowContext(ctx, `SELECT is_archived FROM channels WHERE id = $1`, id).Scan(&isArchived) if err != nil { return fmt.Errorf("channel not found") } if !isArchived { 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 = $1`, id) return err } // ── CS1 additions (v0.29.0) ───────────────────────────────────────────── func (s *ChannelStore) FindExistingDM(ctx context.Context, userID1, userID2 string) (string, error) { var channelID string err := DB.QueryRowContext(ctx, ` 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 `, userID1, userID2).Scan(&channelID) if err == sql.ErrNoRows { return "", nil } return channelID, err } func (s *ChannelStore) GetUnreadCount(ctx context.Context, channelID, userID string) (int, error) { var count int err := DB.QueryRowContext(ctx, ` 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 `, channelID, userID).Scan(&count) return count, err } func (s *ChannelStore) DeleteByOwner(ctx context.Context, channelID, userID string) (int64, error) { result, err := DB.ExecContext(ctx, `DELETE FROM channels WHERE id = $1 AND user_id = $2`, channelID, userID) if err != nil { return 0, err } return result.RowsAffected() } func (s *ChannelStore) ArchiveForRetention(ctx context.Context, channelID string, purgeAfter time.Time) error { _, err := DB.ExecContext(ctx, ` UPDATE channels SET is_archived = true, purge_after = $2, updated_at = NOW() WHERE id = $1 `, channelID, purgeAfter) return err } func (s *ChannelStore) ListPurgeable(ctx context.Context) ([]string, error) { rows, err := DB.QueryContext(ctx, ` SELECT id FROM channels WHERE purge_after IS NOT NULL AND purge_after <= NOW() `) if err != nil { return nil, err } defer rows.Close() var ids []string for rows.Next() { var id string if rows.Scan(&id) == nil { ids = append(ids, id) } } return ids, rows.Err() } func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error { // Update last_read_at _, err := DB.ExecContext(ctx, ` 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 { return nil // participant may not exist for legacy chats } // Best-effort: update last_read_message_id var latestMsgID *string _ = DB.QueryRowContext(ctx, ` SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1 `, channelID).Scan(&latestMsgID) if latestMsgID != nil { _, _ = DB.ExecContext(ctx, ` UPDATE channel_participants SET last_read_message_id = $1 WHERE channel_id = $2 AND participant_type = 'user' AND participant_id = $3 `, *latestMsgID, channelID, userID) } return nil } func (s *ChannelStore) CountParticipantsByType(ctx context.Context, channelID, pType string) (int, error) { var count int err := DB.QueryRowContext(ctx, ` SELECT COUNT(*) FROM channel_participants WHERE channel_id = $1 AND participant_type = $2 `, channelID, pType).Scan(&count) return count, err } // ── CS2 additions (v0.29.0) ───────────────────────────────────────────── func (s *ChannelStore) CountAll(ctx context.Context) (int, error) { var count int err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&count) return count, err } // ── Workflow instance state (v0.29.0-cs3) ─────────────────────────────── func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error { now := time.Now().UTC() _, err := DB.ExecContext(ctx, ` UPDATE channels SET workflow_id = $1, workflow_version = $2, current_stage = 0, stage_data = $3, workflow_status = $4, last_activity_at = $5, stage_entered_at = $6 WHERE id = $7 `, workflowID, version, stageData, status, now, now, channelID) return err } func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string) (*store.WorkflowChannelStatus, error) { var ws store.WorkflowChannelStatus var stageData []byte err := DB.QueryRowContext(ctx, ` SELECT workflow_id, workflow_version, current_stage, COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'), last_activity_at, stage_entered_at FROM channels WHERE id = $1 AND type = 'workflow' `, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion, &ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt, &ws.StageEnteredAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } ws.StageData = stageData return &ws, nil } func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error { now := time.Now().UTC() _, err := DB.ExecContext(ctx, ` UPDATE channels SET current_stage = $1, stage_data = $2, last_activity_at = $3, stage_entered_at = $4 WHERE id = $5 `, nextStage, stageData, now, now, channelID) return err } func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, finalStage int, stageData json.RawMessage) error { _, err := DB.ExecContext(ctx, ` UPDATE channels SET current_stage = $1, workflow_status = 'completed', stage_data = $2, last_activity_at = $3, ai_mode = 'off' WHERE id = $4 `, finalStage, stageData, time.Now().UTC(), channelID) return err } func (s *ChannelStore) CancelWorkflow(ctx context.Context, channelID string) error { _, err := DB.ExecContext(ctx, ` UPDATE channels SET workflow_status = 'cancelled', ai_mode = 'off', last_activity_at = $1 WHERE id = $2 AND type = 'workflow' `, time.Now().UTC(), channelID) return err } func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error { now := time.Now().UTC() _, err := DB.ExecContext(ctx, ` UPDATE channels SET current_stage = $1, last_activity_at = $2, stage_entered_at = $3 WHERE id = $4 `, stage, now, now, channelID) return err } func (s *ChannelStore) GetStageData(ctx context.Context, channelID string) (json.RawMessage, error) { var data json.RawMessage err := DB.QueryRowContext(ctx, ` SELECT COALESCE(stage_data, '{}') FROM channels WHERE id = $1 `, channelID).Scan(&data) return data, err } // ── Background job helpers (v0.29.0-cs4) ──────────────────────────────── func (s *ChannelStore) MarkStaleWorkflows(ctx context.Context, cutoff time.Time) (int64, error) { result, err := DB.ExecContext(ctx, ` UPDATE channels SET workflow_status = 'stale' WHERE type = 'workflow' AND workflow_status = 'active' AND last_activity_at < $1 `, cutoff) if err != nil { return 0, err } return result.RowsAffected() } func (s *ChannelStore) EnforceWorkflowRetention(ctx context.Context) (int64, error) { result, err := DB.ExecContext(ctx, ` DELETE FROM channels WHERE type = 'workflow' AND workflow_status IN ('completed', 'archived') AND workflow_id IS NOT NULL AND workflow_id IN ( SELECT id FROM workflows WHERE retention IS NOT NULL AND retention->>'mode' = 'delete' AND (retention->>'delete_after_days')::int > 0 AND channels.last_activity_at < now() - ((retention->>'delete_after_days')::int || ' days')::interval ) `) if err != nil { return 0, err } return result.RowsAffected() } func (s *ChannelStore) GetTypeAndAllowAnonymous(ctx context.Context, channelID string) (string, bool, error) { var chType string var allowAnon bool err := DB.QueryRowContext(ctx, `SELECT type, allow_anonymous FROM channels WHERE id = $1`, channelID).Scan(&chType, &allowAnon) return chType, allowAnon, err } // ── CS5b additions (v0.29.0) ──────────────────────────────────────────── func (s *ChannelStore) FindCompactionCandidates(ctx context.Context, activityBefore, createdAfter time.Time, minMessages, minChars, limit int) ([]models.Channel, error) { rows, err := DB.QueryContext(ctx, ` SELECT c.id, c.user_id, COALESCE(c.model, ''), COALESCE(c.settings::text, '{}'), COUNT(m.id) AS msg_count, COALESCE(SUM(LENGTH(m.content)), 0) AS total_chars FROM channels c JOIN messages m ON m.channel_id = c.id AND m.deleted_at IS NULL WHERE c.type = 'direct' AND c.is_archived = false AND c.updated_at < $1 AND c.updated_at > $2 GROUP BY c.id HAVING COUNT(m.id) >= $3 AND COALESCE(SUM(LENGTH(m.content)), 0) > $4 ORDER BY c.updated_at DESC LIMIT $5 `, activityBefore, createdAfter, minMessages, minChars, limit) if err != nil { return nil, err } defer rows.Close() var result []models.Channel for rows.Next() { var ch models.Channel var settingsRaw string var msgCount, totalChars int if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Model, &settingsRaw, &msgCount, &totalChars); err != nil { continue } ch.Settings = models.JSONMap{} _ = json.Unmarshal([]byte(settingsRaw), &ch.Settings) result = append(result, ch) } if result == nil { result = []models.Channel{} } return result, rows.Err() } // ── CS7a additions (v0.29.0) ──────────────────────────────────────────── func (s *ChannelStore) GetProviderConfigID(ctx context.Context, channelID string) (*string, error) { var configID sql.NullString err := DB.QueryRowContext(ctx, `SELECT provider_config_id FROM channels WHERE id = $1`, channelID).Scan(&configID) if err != nil { return nil, err } return NullableStringPtr(configID), nil } func (s *ChannelStore) UserCanAccess(ctx context.Context, channelID, userID string) (bool, error) { var ok bool err := DB.QueryRowContext(ctx, ` SELECT EXISTS( SELECT 1 FROM channels WHERE id = $1 AND user_id = $2 UNION ALL SELECT 1 FROM channel_participants WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2 LIMIT 1 )`, channelID, userID).Scan(&ok) return ok, err } // ── CS7b additions (v0.29.0) ──────────────────────────────────────────── const channelListCols = `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.folder_id, c.project_id, c.workspace_id, c.tags, c.settings, COALESCE(mc.cnt, 0) AS message_count, c.created_at, c.updated_at` const channelListFrom = `channels c LEFT JOIN ( SELECT channel_id, COUNT(*) AS cnt FROM messages WHERE deleted_at IS NULL GROUP BY channel_id ) mc ON mc.channel_id = c.id` func (s *ChannelStore) ListFiltered(ctx context.Context, userID string, f store.ChannelListFilter) ([]store.ChannelListItem, int, error) { b := NewSelect(channelListCols, channelListFrom) b.Where("(c.user_id = ? OR c.id IN (SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = ?))", userID, userID) b.Where("c.is_archived = ?", f.Archived) if len(f.Types) == 1 { b.Where("c.type = ?", f.Types[0]) } else if len(f.Types) > 1 { placeholders := make([]string, len(f.Types)) for i, t := range f.Types { b.argIdx++ placeholders[i] = fmt.Sprintf("$%d", b.argIdx) b.args = append(b.args, t) } b.WhereRaw("c.type IN (" + strings.Join(placeholders, ",") + ")") } if f.Folder != "" { b.Where("c.folder = ?", f.Folder) } if f.FolderID != "" { b.Where("c.folder_id = ?", f.FolderID) } if f.Search != "" { b.Where("c.title ILIKE ?", "%"+f.Search+"%") } if f.ProjectID == "none" { b.WhereRaw("c.project_id IS NULL") } else if f.ProjectID != "" { b.Where("c.project_id = ?", f.ProjectID) } // Count countQ, countArgs := b.CountBuild() var total int DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total) b.OrderBy("c.is_pinned DESC, c.updated_at", "DESC") b.Paginate(f.ListOptions) q, args := b.Build() rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, 0, err } defer rows.Close() items, err := scanChannelListItems(rows) if err != nil { return nil, 0, err } // Compute unread counts for i := range items { DB.QueryRowContext(ctx, ` 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 AND m.deleted_at IS NULL `, items[i].ID, userID).Scan(&items[i].UnreadCount) } return items, total, nil } func (s *ChannelStore) GetForUser(ctx context.Context, channelID, userID string) (*store.ChannelListItem, error) { row := DB.QueryRowContext(ctx, fmt.Sprintf(` SELECT %s FROM %s WHERE c.id = $1 AND (c.user_id = $2 OR c.id IN ( SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $2 )) `, channelListCols, channelListFrom), channelID, userID) var item store.ChannelListItem var tags []byte var settings []byte err := row.Scan( &item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic, &item.Description, &item.Model, &item.ProviderConfigID, &item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID, &tags, &settings, &item.MessageCount, &item.CreatedAtTime, &item.UpdatedAtTime, ) if err != nil { return nil, err } item.Tags = scanTagsBytes(tags) item.Settings = safeJSONBytes(settings) item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z") item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z") return &item, nil } func (s *ChannelStore) CreateFull(ctx context.Context, ch *models.Channel, folder string, tags []string, aiMode string, ownerUserID string, dmPartnerIDs []string, defaultModel, defaultConfigID string) error { tx, err := DB.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if tags == nil { tags = []string{} } // Insert channel err = tx.QueryRowContext(ctx, ` INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, folder_id, tags, ai_mode) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at`, ch.UserID, ch.Title, ch.Type, ch.Description, ch.Model, ch.SystemPrompt, models.NullString(ch.ProviderConfigID), folder, models.NullString(ch.FolderID), pq.Array(tags), aiMode, ).Scan(&ch.ID, &ch.CreatedAt, &ch.UpdatedAt) if err != nil { return fmt.Errorf("CreateFull insert channel: %w", err) } // Add owner participant _, err = tx.ExecContext(ctx, ` INSERT INTO channel_participants (channel_id, participant_type, participant_id, role) VALUES ($1, 'user', $2, 'owner') ON CONFLICT DO NOTHING`, ch.ID, ownerUserID) if err != nil { return fmt.Errorf("CreateFull add owner: %w", err) } // Add DM partner participants for _, pid := range dmPartnerIDs { if pid == ownerUserID { continue } _, _ = tx.ExecContext(ctx, ` 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 defaultModel != "" { _, _ = tx.ExecContext(ctx, ` INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default) VALUES ($1, $2, $3, true) ON CONFLICT DO NOTHING`, ch.ID, defaultModel, models.NullString(&defaultConfigID)) } return tx.Commit() } func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error { _, err := DB.ExecContext(ctx, `UPDATE channels SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb WHERE id = $2`, []byte(settingsJSON), channelID) return err } // ── Monitoring (v0.35.0) ──────────────────── func (s *ChannelStore) ListByType(ctx context.Context, channelType string) ([]models.Channel, error) { rows, err := DB.QueryContext(ctx, ` SELECT id, user_id, title, COALESCE(description, ''), type, team_id, created_at FROM channels WHERE type = $1 ORDER BY created_at DESC`, channelType) if err != nil { return nil, err } defer rows.Close() var result []models.Channel for rows.Next() { var ch models.Channel if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description, &ch.Type, &ch.TeamID, &ch.CreatedAt); err != nil { return nil, err } result = append(result, ch) } return result, rows.Err() } // ── CS7b helpers ──────────────────────────── func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) { var result []store.ChannelListItem for rows.Next() { var item store.ChannelListItem var tags []byte var settings []byte err := rows.Scan( &item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic, &item.Description, &item.Model, &item.ProviderConfigID, &item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID, &tags, &settings, &item.MessageCount, &item.CreatedAtTime, &item.UpdatedAtTime, ) if err != nil { return nil, err } item.Tags = scanTagsBytes(tags) item.Settings = safeJSONBytes(settings) item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z") item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z") result = append(result, item) } return result, rows.Err() } func scanTagsBytes(b []byte) []string { if len(b) == 0 { return []string{} } // Try JSON array first (SQLite path), then PG text[] format var arr []string if json.Unmarshal(b, &arr) == nil { if arr == nil { return []string{} } return arr } // PG text[] format: {tag1,tag2} s := strings.TrimPrefix(strings.TrimSuffix(string(b), "}"), "{") if s == "" { return []string{} } return strings.Split(s, ",") } func safeJSONBytes(b []byte) json.RawMessage { if len(b) == 0 || !json.Valid(b) { return json.RawMessage("{}") } cp := make([]byte, len(b)) copy(cp, b) return json.RawMessage(cp) }