This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/store/postgres/channel.go
2026-03-17 16:28:47 +00:00

966 lines
32 KiB
Go

package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/lib/pq"
"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 {
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 id, channel_id, participant_type, participant_id, role,
display_name, avatar_url, joined_at
FROM channel_participants WHERE channel_id = $1 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
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) 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 {
_, 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
WHERE id = $6
`, workflowID, version, stageData, status, time.Now().UTC(), 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
FROM channels WHERE id = $1 AND type = 'workflow'
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
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 {
_, err := DB.ExecContext(ctx, `
UPDATE channels
SET current_stage = $1, stage_data = $2, last_activity_at = $3
WHERE id = $4
`, nextStage, stageData, time.Now().UTC(), 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) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
_, err := DB.ExecContext(ctx, `
UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3
`, stage, time.Now().UTC(), 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
}
// ── 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)
}