package postgres import ( "context" "database/sql" "encoding/json" "fmt" "time" "switchboard-core/models" "switchboard-core/store" ) type MessageStore struct{} func NewMessageStore() *MessageStore { return &MessageStore{} } func (s *MessageStore) Create(ctx context.Context, m *models.Message) error { toolCallsJSON := ToJSON(m.ToolCalls) metadataJSON := ToJSON(m.Metadata) return DB.QueryRowContext(ctx, ` INSERT INTO messages (channel_id, role, content, model, tokens_used, tool_calls, metadata, parent_id, sibling_index, participant_type, participant_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id, created_at`, m.ChannelID, m.Role, m.Content, m.Model, m.TokensUsed, toolCallsJSON, metadataJSON, models.NullString(m.ParentID), m.SiblingIndex, m.ParticipantType, m.ParticipantID, ).Scan(&m.ID, &m.CreatedAt) } func (s *MessageStore) GetByID(ctx context.Context, id string) (*models.Message, error) { var m models.Message var parentID sql.NullString var toolCallsJSON, metadataJSON []byte var deletedAt sql.NullTime err := DB.QueryRowContext(ctx, ` SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata, parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at FROM messages WHERE id = $1`, id).Scan( &m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, &toolCallsJSON, &metadataJSON, &parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID, &deletedAt, &m.CreatedAt, ) if err != nil { return nil, err } m.ParentID = NullableStringPtr(parentID) json.Unmarshal(toolCallsJSON, &m.ToolCalls) json.Unmarshal(metadataJSON, &m.Metadata) if deletedAt.Valid { m.DeletedAt = &deletedAt.Time } return &m, nil } func (s *MessageStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { b := NewUpdate("messages") for k, v := range fields { if k == "tool_calls" || k == "metadata" { 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 *MessageStore) Delete(ctx context.Context, id string) error { now := time.Now() _, err := DB.ExecContext(ctx, "UPDATE messages SET deleted_at = $1 WHERE id = $2", now, id) return err } func (s *MessageStore) ListForChannel(ctx context.Context, channelID string, opts store.ListOptions) ([]models.Message, error) { b := NewSelect( "id, channel_id, role, content, model, tokens_used, tool_calls, metadata, parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at", "messages", ).Where("channel_id = ?", channelID).WhereRaw("deleted_at IS NULL") if opts.Sort == "" { b.OrderBy("created_at", "ASC") } b.Paginate(opts) q, args := b.Build() rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() return scanMessages(rows) } func (s *MessageStore) GetChildren(ctx context.Context, parentID string) ([]models.Message, error) { rows, err := DB.QueryContext(ctx, ` SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata, parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at FROM messages WHERE parent_id = $1 AND deleted_at IS NULL ORDER BY sibling_index`, parentID) if err != nil { return nil, err } defer rows.Close() return scanMessages(rows) } func (s *MessageStore) GetSiblings(ctx context.Context, messageID string) ([]models.Message, error) { rows, err := DB.QueryContext(ctx, ` SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata, parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at FROM messages WHERE parent_id = (SELECT parent_id FROM messages WHERE id = $1) AND deleted_at IS NULL ORDER BY sibling_index`, messageID) if err != nil { return nil, err } defer rows.Close() return scanMessages(rows) } func (s *MessageStore) GetPathToRoot(ctx context.Context, messageID string) ([]models.Message, error) { rows, err := DB.QueryContext(ctx, ` WITH RECURSIVE path AS ( SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata, parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at FROM messages WHERE id = $1 UNION ALL SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata, m.parent_id, m.sibling_index, m.participant_type, m.participant_id, m.deleted_at, m.created_at FROM messages m JOIN path p ON m.id = p.parent_id ) SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata, parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at FROM path ORDER BY created_at ASC`, messageID) if err != nil { return nil, err } defer rows.Close() return scanMessages(rows) } func (s *MessageStore) GetNextSiblingIndex(ctx context.Context, parentID string) (int, error) { var maxIdx sql.NullInt64 err := DB.QueryRowContext(ctx, "SELECT MAX(sibling_index) FROM messages WHERE parent_id = $1 AND deleted_at IS NULL", parentID).Scan(&maxIdx) if err != nil || !maxIdx.Valid { return 0, err } return int(maxIdx.Int64) + 1, nil } func (s *MessageStore) CountForChannel(ctx context.Context, channelID string) (int, error) { var count int err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL", channelID).Scan(&count) return count, err } func scanMessages(rows *sql.Rows) ([]models.Message, error) { var result []models.Message for rows.Next() { var m models.Message var parentID sql.NullString var toolCallsJSON, metadataJSON []byte var deletedAt sql.NullTime err := rows.Scan( &m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, &toolCallsJSON, &metadataJSON, &parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID, &deletedAt, &m.CreatedAt, ) if err != nil { return nil, err } m.ParentID = NullableStringPtr(parentID) json.Unmarshal(toolCallsJSON, &m.ToolCalls) json.Unmarshal(metadataJSON, &m.Metadata) if deletedAt.Valid { m.DeletedAt = &deletedAt.Time } result = append(result, m) } return result, rows.Err() } // ── CS2 additions (v0.29.0) ───────────────────────────────────────────── func (s *MessageStore) CountAll(ctx context.Context) (int, error) { var count int err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&count) return count, err } // ── CS5c additions (v0.29.0) ───────────────────────────────────────────── func (s *MessageStore) SearchInChannel(ctx context.Context, channelID, query, roleFilter string, limit int) ([]store.ChannelSearchResult, error) { roleClause := "" queryArgs := []interface{}{channelID, query, limit} if roleFilter == "user" || roleFilter == "assistant" { roleClause = "AND m.role = $4" queryArgs = append(queryArgs, roleFilter) } rows, err := DB.QueryContext(ctx, fmt.Sprintf(` SELECT m.id, m.role, ts_headline('english', m.content, plainto_tsquery('english', $2), 'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline, ts_rank(to_tsvector('english', m.content), plainto_tsquery('english', $2)) AS rank, m.created_at FROM messages m WHERE m.channel_id = $1 AND m.deleted_at IS NULL AND m.role IN ('user', 'assistant') AND to_tsvector('english', m.content) @@ plainto_tsquery('english', $2) %s ORDER BY rank DESC, m.created_at DESC LIMIT $3 `, roleClause), queryArgs...) if err != nil { return nil, err } defer rows.Close() results := make([]store.ChannelSearchResult, 0) for rows.Next() { var r store.ChannelSearchResult if err := rows.Scan(&r.MessageID, &r.Role, &r.Excerpt, &r.Rank, &r.Timestamp); err != nil { continue } results = append(results, r) } return results, rows.Err() } // ── CS7a additions (v0.29.0) ──────────────────────────────────────────── func (s *MessageStore) ListWithSenderInfo(ctx context.Context, channelID string, limit, offset int) ([]store.MessageWithSender, int, error) { var total int err := DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`, channelID).Scan(&total) if err != nil { return nil, 0, err } rows, err := DB.QueryContext(ctx, ` SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id, m.sibling_index, m.participant_type, m.participant_id, CASE WHEN m.participant_type = 'user' THEN COALESCE(NULLIF(u.display_name, ''), u.username) WHEN m.participant_type = 'persona' THEN p.name ELSE NULL END AS sender_name, CASE WHEN m.participant_type = 'user' THEN u.avatar_url WHEN m.participant_type = 'persona' THEN p.avatar ELSE NULL END AS sender_avatar, m.created_at FROM messages m LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text WHERE m.channel_id = $1 AND m.deleted_at IS NULL ORDER BY m.created_at ASC LIMIT $2 OFFSET $3 `, channelID, limit, offset) if err != nil { return nil, 0, err } defer rows.Close() results := make([]store.MessageWithSender, 0) for rows.Next() { var m store.MessageWithSender if err := rows.Scan( &m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, &m.ParentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID, &m.SenderName, &m.SenderAvatar, &m.CreatedAt, ); err != nil { return nil, total, err } results = append(results, m) } return results, total, rows.Err() } func (s *MessageStore) GetParentAndRole(ctx context.Context, messageID, channelID string) (*string, string, error) { var parentID sql.NullString var role string err := DB.QueryRowContext(ctx, ` SELECT parent_id, role FROM messages WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL `, messageID, channelID).Scan(&parentID, &role) if err != nil { return nil, "", err } return NullableStringPtr(parentID), role, nil }