480 lines
16 KiB
Go
480 lines
16 KiB
Go
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)
|
|
}
|
|
// 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 (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, provCfgID, 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
|
|
}
|