Changeset 0.17.1 (#76)

This commit is contained in:
2026-02-28 01:40:31 +00:00
parent c9141a6896
commit 856dc9b0ac
64 changed files with 8037 additions and 1657 deletions

10
server/store/id.go Normal file
View File

@@ -0,0 +1,10 @@
package store
import "github.com/google/uuid"
// NewID generates a new UUID string.
// Used by SQLite stores where gen_random_uuid() is not available.
// Also usable by Postgres stores — application-side IDs are always valid.
func NewID() string {
return uuid.New().String()
}

View File

@@ -0,0 +1,190 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type AttachmentStore struct{}
func NewAttachmentStore() *AttachmentStore { return &AttachmentStore{} }
// ── columns shared across queries ──────────
const attachmentCols = `id, channel_id, user_id, message_id, filename, content_type,
size_bytes, storage_key, extracted_text, metadata, created_at`
// scanAttachment scans a row into an Attachment struct.
func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) {
var a models.Attachment
var messageID sql.NullString
var extractedText sql.NullString
var metadataJSON []byte
err := row.Scan(
&a.ID, &a.ChannelID, &a.UserID, &messageID,
&a.Filename, &a.ContentType, &a.SizeBytes,
&a.StorageKey, &extractedText, &metadataJSON, st(&a.CreatedAt),
)
if err != nil {
return nil, err
}
a.MessageID = NullableStringPtr(messageID)
if extractedText.Valid {
a.ExtractedText = &extractedText.String
}
if len(metadataJSON) > 0 {
json.Unmarshal(metadataJSON, &a.Metadata)
}
return &a, nil
}
func (s *AttachmentStore) Create(ctx context.Context, a *models.Attachment) error {
a.ID = store.NewID()
a.CreatedAt = time.Now().UTC()
_, err := DB.ExecContext(ctx, `
INSERT INTO attachments (id, channel_id, user_id, message_id, filename, content_type,
size_bytes, storage_key, extracted_text, metadata, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
a.ID, a.ChannelID, a.UserID, models.NullString(a.MessageID),
a.Filename, a.ContentType, a.SizeBytes,
a.StorageKey, models.NullString(a.ExtractedText),
ToJSON(a.Metadata), a.CreatedAt.Format(timeFmt),
)
return err
}
func (s *AttachmentStore) GetByID(ctx context.Context, id string) (*models.Attachment, error) {
row := DB.QueryRowContext(ctx, `SELECT `+attachmentCols+` FROM attachments WHERE id = ?`, id)
return scanAttachment(row)
}
func (s *AttachmentStore) GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error) {
rows, err := DB.QueryContext(ctx,
`SELECT `+attachmentCols+` FROM attachments WHERE channel_id = ? ORDER BY created_at`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
func (s *AttachmentStore) GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error) {
rows, err := DB.QueryContext(ctx,
`SELECT `+attachmentCols+` FROM attachments WHERE message_id = ? ORDER BY created_at`, messageID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
func (s *AttachmentStore) SetMessageID(ctx context.Context, attachmentID, messageID string) error {
_, err := DB.ExecContext(ctx,
`UPDATE attachments SET message_id = ? WHERE id = ?`,
messageID, attachmentID)
return err
}
func (s *AttachmentStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error {
// Merge into existing metadata using jsonb || operator
metaJSON, err := json.Marshal(metadata)
if err != nil {
return err
}
_, err = DB.ExecContext(ctx,
`UPDATE attachments SET metadata = metadata || ? WHERE id = ?`,
metaJSON, id)
return err
}
func (s *AttachmentStore) SetExtractedText(ctx context.Context, id string, text string) error {
_, err := DB.ExecContext(ctx,
`UPDATE attachments SET extracted_text = ? WHERE id = ?`,
text, id)
return err
}
// Delete removes an attachment and returns the deleted row (for storage cleanup).
func (s *AttachmentStore) Delete(ctx context.Context, id string) (*models.Attachment, error) {
row := DB.QueryRowContext(ctx,
`DELETE FROM attachments WHERE id = ?
RETURNING `+attachmentCols, id)
return scanAttachment(row)
}
// DeleteByChannel removes all attachments for a channel and returns storage keys.
func (s *AttachmentStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) {
rows, err := DB.QueryContext(ctx,
`DELETE FROM attachments WHERE channel_id = ? RETURNING storage_key`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return keys, err
}
keys = append(keys, key)
}
return keys, rows.Err()
}
func (s *AttachmentStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) {
var total sql.NullInt64
err := DB.QueryRowContext(ctx,
`SELECT COALESCE(SUM(size_bytes), 0) FROM attachments WHERE user_id = ?`,
userID).Scan(&total)
if err != nil {
return 0, err
}
return total.Int64, nil
}
func (s *AttachmentStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.Attachment, error) {
cutoff := time.Now().Add(-olderThan)
rows, err := DB.QueryContext(ctx,
`SELECT `+attachmentCols+` FROM attachments
WHERE message_id IS NULL AND created_at < ?
ORDER BY created_at`, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,84 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type AuditStore struct{}
func NewAuditStore() *AuditStore { return &AuditStore{} }
func (s *AuditStore) Log(ctx context.Context, entry *models.AuditEntry) error {
entry.ID = store.NewID()
entry.CreatedAt = time.Now().UTC()
metadataJSON := ToJSON(entry.Metadata)
_, err := DB.ExecContext(ctx, `
INSERT INTO audit_log (id, actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
entry.ID, models.NullString(entry.ActorID), entry.Action, entry.ResourceType,
entry.ResourceID, metadataJSON, entry.IPAddress, entry.UserAgent,
entry.CreatedAt.Format(timeFmt),
)
return err
}
func (s *AuditStore) List(ctx context.Context, opts store.AuditListOptions) ([]models.AuditEntry, int, error) {
b := NewSelect("id, actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent, created_at", "audit_log")
if opts.ActorID != "" {
b.Where("actor_id = ?", opts.ActorID)
}
if opts.Action != "" {
b.Where("action = ?", opts.Action)
}
if opts.ResourceType != "" {
b.Where("resource_type = ?", opts.ResourceType)
}
if opts.ResourceID != "" {
b.Where("resource_id = ?", opts.ResourceID)
}
if opts.Since != nil {
b.Where("created_at >= ?", *opts.Since)
}
if opts.Until != nil {
b.Where("created_at <= ?", *opts.Until)
}
countQ, countArgs := b.CountBuild()
var total int
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
if opts.Sort == "" {
b.OrderBy("created_at", "DESC")
}
b.Paginate(opts.ListOptions)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var result []models.AuditEntry
for rows.Next() {
var e models.AuditEntry
var actorID sql.NullString
var metadataJSON string
err := rows.Scan(&e.ID, &actorID, &e.Action, &e.ResourceType, &e.ResourceID,
&metadataJSON, &e.IPAddress, &e.UserAgent, st(&e.CreatedAt))
if err != nil {
return nil, 0, err
}
e.ActorID = NullableStringPtr(actorID)
json.Unmarshal([]byte(metadataJSON), &e.Metadata)
result = append(result, e)
}
return result, total, rows.Err()
}

View File

@@ -0,0 +1,241 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type CatalogStore struct{}
func NewCatalogStore() *CatalogStore { return &CatalogStore{} }
const catalogCols = `id, provider_config_id, model_id, display_name, model_type,
capabilities, pricing, visibility, last_synced_at, created_at, updated_at`
// catalogColsMC is catalogCols with mc. prefix for use in JOINs
// where id/created_at/updated_at are ambiguous.
const catalogColsMC = `mc.id, mc.provider_config_id, mc.model_id, mc.display_name, mc.model_type,
mc.capabilities, mc.pricing, mc.visibility, mc.last_synced_at, mc.created_at, mc.updated_at`
// UpsertFromSync bulk-inserts or updates catalog entries from a provider API fetch.
// New models default to 'disabled' visibility (secure by default).
func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID string, entries []store.CatalogSyncEntry) (added, updated int, err error) {
now := time.Now()
for _, e := range entries {
capsJSON := ToJSON(e.Capabilities)
pricingJSON := ToJSON(e.Pricing) // nil → "{}", always valid JSONB
// Normalize model type: empty → "chat" (the default)
modelType := e.ModelType
if modelType == "" {
modelType = "chat"
}
var existingID string
err := DB.QueryRowContext(ctx,
"SELECT id FROM model_catalog WHERE provider_config_id = ? AND model_id = ?",
providerConfigID, e.ModelID,
).Scan(&existingID)
if err == sql.ErrNoRows {
// Insert new (disabled by default)
_, err = DB.ExecContext(ctx, `
INSERT INTO model_catalog (id, provider_config_id, model_id, display_name, model_type,
capabilities, pricing, visibility, last_synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'disabled', ?)`,
store.NewID(), providerConfigID, e.ModelID, e.DisplayName, modelType, capsJSON, pricingJSON, now)
if err != nil {
return added, updated, fmt.Errorf("insert %s: %w", e.ModelID, err)
}
added++
} else if err == nil {
// Update existing (preserve visibility)
_, err = DB.ExecContext(ctx, `
UPDATE model_catalog SET display_name = ?, model_type = ?, capabilities = ?,
pricing = ?, last_synced_at = ?
WHERE id = ?`,
e.DisplayName, modelType, capsJSON, pricingJSON, now, existingID)
if err != nil {
return added, updated, fmt.Errorf("update %s: %w", e.ModelID, err)
}
updated++
} else {
return added, updated, fmt.Errorf("check %s: %w", e.ModelID, err)
}
}
return added, updated, nil
}
func (s *CatalogStore) GetByID(ctx context.Context, id string) (*models.CatalogEntry, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE id = ?", catalogCols), id)
return scanCatalogEntry(row)
}
func (s *CatalogStore) GetByModelID(ctx context.Context, providerConfigID, modelID string) (*models.CatalogEntry, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = ? AND model_id = ?", catalogCols),
providerConfigID, modelID)
return scanCatalogEntry(row)
}
// GetByModelIDAny returns the most recently synced catalog entry for a model_id
// across any provider. Used to resolve capabilities for presets with auto-resolve
// (no specific provider_config_id).
func (s *CatalogStore) GetByModelIDAny(ctx context.Context, modelID string) (*models.CatalogEntry, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE model_id = ? ORDER BY last_synced_at DESC NULLS LAST LIMIT 1", catalogCols),
modelID)
return scanCatalogEntry(row)
}
func (s *CatalogStore) ListVisible(ctx context.Context) ([]models.CatalogEntry, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf(`SELECT %s FROM model_catalog mc
JOIN provider_configs pc ON pc.id = mc.provider_config_id
WHERE mc.visibility = 'enabled' AND pc.scope = 'global' AND pc.is_active = 1
ORDER BY mc.model_id`, catalogColsMC))
if err != nil {
return nil, err
}
defer rows.Close()
return scanCatalogEntries(rows)
}
func (s *CatalogStore) ListForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = ? ORDER BY model_id", catalogCols),
providerConfigID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCatalogEntries(rows)
}
func (s *CatalogStore) ListEnabledForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = ? AND visibility = 'enabled' ORDER BY model_id", catalogCols),
providerConfigID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCatalogEntries(rows)
}
func (s *CatalogStore) ListAll(ctx context.Context) ([]models.CatalogEntry, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog ORDER BY model_id", catalogCols))
if err != nil {
return nil, err
}
defer rows.Close()
return scanCatalogEntries(rows)
}
// ListAllGlobal returns all catalog entries whose provider_config has scope='global'.
// Used by admin panel — admin should not see user BYOK or team-level models.
func (s *CatalogStore) ListAllGlobal(ctx context.Context) ([]models.CatalogEntry, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf(`SELECT %s FROM model_catalog mc
JOIN provider_configs pc ON pc.id = mc.provider_config_id
WHERE pc.scope = 'global'
ORDER BY mc.model_id`, catalogColsMC))
if err != nil {
return nil, err
}
defer rows.Close()
return scanCatalogEntries(rows)
}
func (s *CatalogStore) SetVisibility(ctx context.Context, id string, visibility string) error {
_, err := DB.ExecContext(ctx,
"UPDATE model_catalog SET visibility = ? WHERE id = ?", visibility, id)
return err
}
func (s *CatalogStore) BulkSetVisibility(ctx context.Context, providerConfigID string, visibility string) error {
_, err := DB.ExecContext(ctx,
"UPDATE model_catalog SET visibility = ? WHERE provider_config_id = ?",
visibility, providerConfigID)
return err
}
func (s *CatalogStore) BulkSetVisibilityAll(ctx context.Context, visibility string) error {
_, err := DB.ExecContext(ctx,
`UPDATE model_catalog SET visibility = ?
WHERE provider_config_id IN (
SELECT id FROM provider_configs WHERE scope = 'global'
)`, visibility)
return err
}
func (s *CatalogStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM model_catalog WHERE id = ?", id)
return err
}
func (s *CatalogStore) DeleteForProvider(ctx context.Context, providerConfigID string) error {
_, err := DB.ExecContext(ctx,
"DELETE FROM model_catalog WHERE provider_config_id = ?", providerConfigID)
return err
}
// ── Scanners ────────────────────────────────
func scanCatalogEntry(row *sql.Row) (*models.CatalogEntry, error) {
var e models.CatalogEntry
var capsJSON, pricingJSON []byte
var displayName sql.NullString
var lastSynced *time.Time
err := row.Scan(
&e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, &e.ModelType,
&capsJSON, &pricingJSON, &e.Visibility, stN(&lastSynced),
st(&e.CreatedAt), st(&e.UpdatedAt),
)
if err != nil {
return nil, err
}
e.DisplayName = NullableString(displayName)
json.Unmarshal(capsJSON, &e.Capabilities)
if len(pricingJSON) > 0 {
e.Pricing = &models.ModelPricing{}
json.Unmarshal(pricingJSON, e.Pricing)
}
e.LastSyncedAt = lastSynced
return &e, nil
}
func scanCatalogEntries(rows *sql.Rows) ([]models.CatalogEntry, error) {
result := make([]models.CatalogEntry, 0) // never nil — serializes as [] not null
for rows.Next() {
var e models.CatalogEntry
var capsJSON, pricingJSON []byte
var displayName sql.NullString
var lastSynced *time.Time
err := rows.Scan(
&e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, &e.ModelType,
&capsJSON, &pricingJSON, &e.Visibility, stN(&lastSynced),
st(&e.CreatedAt), st(&e.UpdatedAt),
)
if err != nil {
return nil, err
}
e.DisplayName = NullableString(displayName)
json.Unmarshal(capsJSON, &e.Capabilities)
if len(pricingJSON) > 0 {
e.Pricing = &models.ModelPricing{}
json.Unmarshal(pricingJSON, e.Pricing)
}
e.LastSyncedAt = lastSynced
result = append(result, e)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,227 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"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 {
_, 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) DO UPDATE SET
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.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 id, channel_id, model_id, COALESCE(provider_config_id, ''),
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
FROM channel_models WHERE channel_id = ?`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.ChannelModel
for rows.Next() {
var cm models.ChannelModel
if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
return nil, err
}
result = append(result, cm)
}
return result, rows.Err()
}
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
}

View File

@@ -0,0 +1,201 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type ExtensionStore struct{}
func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} }
func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error {
ext.ID = store.NewID()
now := time.Now().UTC()
ext.CreatedAt = now
ext.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO extensions (id, ext_id, name, version, tier, description, author,
manifest, is_system, is_enabled, scope, team_id, installed_by, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
ext.ID, ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope,
models.NullString(ext.TeamID), models.NullString(ext.InstalledBy),
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = ?`, id)
}
func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = ?`, extID)
}
func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error {
_, err := DB.ExecContext(ctx, `
UPDATE extensions SET
name = ?, version = ?, description = ?, author = ?,
manifest = ?, is_system = ?, is_enabled = ?,
updated_at = datetime('now')
WHERE id = ?`,
ext.Name, ext.Version, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled, id,
)
return err
}
func (s *ExtensionStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = ?`, id)
return err
}
func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`)
}
func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = 1 ORDER BY name`)
}
// ListForUser returns all enabled extensions with user-specific overrides merged in.
// System extensions are always included (users can't disable them).
// Non-system extensions respect per-user enabled toggle.
func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) {
rows, err := DB.QueryContext(ctx, `
SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author,
e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by,
e.created_at, e.updated_at,
eus.is_enabled AS user_enabled,
eus.settings AS user_settings
FROM extensions e
LEFT JOIN extension_user_settings eus
ON eus.extension_id = e.id AND eus.user_id = ?
WHERE e.is_enabled = 1
AND (e.is_system = 1 OR COALESCE(eus.is_enabled, 1) = 1)
ORDER BY e.name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.UserExtension
for rows.Next() {
var ue models.UserExtension
var teamID, installedBy sql.NullString
var userEnabled sql.NullBool
var userSettings []byte
if err := rows.Scan(
&ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier,
&ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem,
&ue.IsEnabled, &ue.Scope, &teamID, &installedBy,
st(&ue.CreatedAt), st(&ue.UpdatedAt),
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
ue.TeamID = NullableStringPtr(teamID)
ue.InstalledBy = NullableStringPtr(installedBy)
if userEnabled.Valid {
ue.UserEnabled = &userEnabled.Bool
}
if userSettings != nil {
raw := json.RawMessage(userSettings)
ue.UserSettings = &raw
}
result = append(result, ue)
}
if result == nil {
result = make([]models.UserExtension, 0)
}
return result, rows.Err()
}
func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) {
var eus models.ExtensionUserSettings
err := DB.QueryRowContext(ctx, `
SELECT extension_id, user_id, settings, is_enabled
FROM extension_user_settings
WHERE extension_id = ? AND user_id = ?`,
extID, userID,
).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled)
if err != nil {
return nil, err
}
return &eus, nil
}
func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled)
VALUES (?, ?, ?, ?)
ON CONFLICT (extension_id, user_id)
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled,
)
return err
}
func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM extension_user_settings WHERE extension_id = ? AND user_id = ?`,
extID, userID,
)
return err
}
// ── Internal helpers ────────────────────────────
func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) {
var ext models.Extension
var teamID, installedBy sql.NullString
err := DB.QueryRowContext(ctx, query, args...).Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
st(&ext.CreatedAt), st(&ext.UpdatedAt),
)
if err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
return &ext, nil
}
func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) {
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Extension
for rows.Next() {
var ext models.Extension
var teamID, installedBy sql.NullString
if err := rows.Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
st(&ext.CreatedAt), st(&ext.UpdatedAt),
); err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
result = append(result, ext)
}
if result == nil {
result = make([]models.Extension, 0)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,58 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type GlobalConfigStore struct{}
func NewGlobalConfigStore() *GlobalConfigStore { return &GlobalConfigStore{} }
func (s *GlobalConfigStore) Get(ctx context.Context, key string) (models.JSONMap, error) {
var valueJSON string
err := DB.QueryRowContext(ctx,
"SELECT value FROM global_settings WHERE key = ?", key).Scan(&valueJSON)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
var result models.JSONMap
json.Unmarshal([]byte(valueJSON), &result)
return result, nil
}
func (s *GlobalConfigStore) Set(ctx context.Context, key string, value models.JSONMap, updatedBy string) error {
valueJSON := ToJSON(value)
_, err := DB.ExecContext(ctx, `
INSERT INTO global_settings (key, value, updated_by, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by, updated_at = datetime('now')`,
key, valueJSON, updatedBy)
return err
}
func (s *GlobalConfigStore) GetAll(ctx context.Context) (map[string]models.JSONMap, error) {
rows, err := DB.QueryContext(ctx, "SELECT key, value FROM global_settings")
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]models.JSONMap)
for rows.Next() {
var key, valueJSON string
if err := rows.Scan(&key, &valueJSON); err != nil {
continue
}
var m models.JSONMap
json.Unmarshal([]byte(valueJSON), &m)
result[key] = m
}
return result, rows.Err()
}

View File

@@ -0,0 +1,213 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── GroupStore ──────────────────────────────
type GroupStore struct{}
func NewGroupStore() *GroupStore { return &GroupStore{} }
func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
g.ID = store.NewID()
now := time.Now().UTC()
g.CreatedAt = now
g.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO groups (id, name, description, scope, team_id, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
g.ID, g.Name, g.Description, g.Scope,
models.NullString(g.TeamID), g.CreatedBy,
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) {
var g models.Group
var teamID sql.NullString
err := DB.QueryRowContext(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count
FROM groups g WHERE g.id = ?`, id).Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy,
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount,
)
if err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
return &g, nil
}
func (s *GroupStore) Update(ctx context.Context, id string, name, description *string) error {
b := NewUpdate("groups")
if name != nil {
b.Set("name", *name)
}
if description != nil {
b.Set("description", *description)
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
res, err := b.Exec(DB)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *GroupStore) Delete(ctx context.Context, id string) error {
res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = ?", id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return sql.ErrNoRows
}
return nil
}
// ── Scoped Listing ──────────────────────────
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
return queryGroups(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
FROM groups g
ORDER BY g.scope, g.name`)
}
func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) {
return queryGroups(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
FROM groups g
WHERE g.scope = 'team' AND g.team_id = ?
ORDER BY g.name`, teamID)
}
func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) {
return queryGroups(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
FROM groups g
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = ?)
ORDER BY g.scope, g.name`, userID)
}
// ── Members ─────────────────────────────────
func (s *GroupStore) AddMember(ctx context.Context, groupID, userID, addedBy string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO group_members (id, group_id, user_id, added_by)
VALUES (?, ?, ?, ?)
ON CONFLICT (group_id, user_id) DO NOTHING`,
store.NewID(), groupID, userID, addedBy)
return err
}
func (s *GroupStore) RemoveMember(ctx context.Context, groupID, userID string) error {
res, err := DB.ExecContext(ctx,
"DELETE FROM group_members WHERE group_id = ? AND user_id = ?",
groupID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *GroupStore) ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error) {
rows, err := DB.QueryContext(ctx, `
SELECT gm.id, gm.group_id, gm.user_id, gm.added_by, gm.added_at,
u.username, u.email, COALESCE(u.display_name, '')
FROM group_members gm
JOIN users u ON u.id = gm.user_id
WHERE gm.group_id = ?
ORDER BY u.username`, groupID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.GroupMember
for rows.Next() {
var m models.GroupMember
if err := rows.Scan(&m.ID, &m.GroupID, &m.UserID, &m.AddedBy, st(&m.AddedAt),
&m.Username, &m.Email, &m.DisplayName); err != nil {
return nil, err
}
result = append(result, m)
}
return result, rows.Err()
}
func (s *GroupStore) IsMember(ctx context.Context, groupID, userID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ?)`,
groupID, userID).Scan(&exists)
return exists, err
}
func (s *GroupStore) GetUserGroupIDs(ctx context.Context, userID string) ([]string, error) {
rows, err := DB.QueryContext(ctx,
"SELECT group_id FROM group_members WHERE user_id = ?", userID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ── Scanners ────────────────────────────────
func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.Group, error) {
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("queryGroups: %w", err)
}
defer rows.Close()
var result []models.Group
for rows.Next() {
var g models.Group
var teamID sql.NullString
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID,
&g.CreatedBy, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount); err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
result = append(result, g)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,328 @@
package sqlite
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// DB is the shared database connection pool.
var DB *sql.DB
// timeFmt is the SQLite-compatible time format for explicit timestamps.
// Must match datetime('now') output used in schema DEFAULT expressions.
const timeFmt = "2006-01-02 15:04:05"
// ── Time Scanner ────────────────────────────
// modernc/sqlite returns TEXT columns as raw strings, not time.Time.
// st() wraps a *time.Time destination so Scan auto-parses the string.
type sqliteTime struct{ t *time.Time }
func st(t *time.Time) *sqliteTime { return &sqliteTime{t: t} }
func (s *sqliteTime) Scan(src interface{}) error {
if src == nil {
return nil
}
switch v := src.(type) {
case time.Time:
*s.t = v
return nil
case string:
for _, f := range timeFormats {
if p, err := time.Parse(f, v); err == nil {
*s.t = p
return nil
}
}
return fmt.Errorf("sqliteTime: cannot parse %q", v)
default:
return fmt.Errorf("sqliteTime: unsupported type %T", src)
}
}
var timeFormats = []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05Z",
time.RFC3339,
"2006-01-02 15:04:05.000000000+00:00",
}
// stN wraps a *time.Time pointer for nullable columns.
type sqliteTimeNullable struct{ t **time.Time }
func stN(t **time.Time) *sqliteTimeNullable { return &sqliteTimeNullable{t: t} }
func (s *sqliteTimeNullable) Scan(src interface{}) error {
if src == nil {
*s.t = nil
return nil
}
var parsed time.Time
scanner := st(&parsed)
if err := scanner.Scan(src); err != nil {
return err
}
*s.t = &parsed
return nil
}
// SetDB configures the shared database connection for all stores.
func SetDB(db *sql.DB) {
DB = db
}
// ── Dynamic SQL Builder (? placeholders) ────
// UpdateBuilder constructs a dynamic UPDATE with ? placeholders.
type UpdateBuilder struct {
table string
sets []string
args []interface{}
where []string
}
func NewUpdate(table string) *UpdateBuilder {
return &UpdateBuilder{table: table}
}
func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder {
b.sets = append(b.sets, col+" = ?")
b.args = append(b.args, val)
return b
}
func (b *UpdateBuilder) SetJSON(col string, val interface{}) *UpdateBuilder {
data, err := json.Marshal(val)
if err != nil {
data = []byte("{}")
}
return b.Set(col, string(data))
}
func (b *UpdateBuilder) SetIf(col string, val interface{}, set bool) *UpdateBuilder {
if set {
return b.Set(col, val)
}
return b
}
// SetExpr sets a column to a raw SQL expression (not parameterized).
func (b *UpdateBuilder) SetExpr(col, expr string) *UpdateBuilder {
b.sets = append(b.sets, col+" = "+expr)
return b
}
// nowExpr returns the SQL expression for "current timestamp" in ISO 8601 format.
const nowExpr = "datetime('now')"
func (b *UpdateBuilder) Where(col string, val interface{}) *UpdateBuilder {
b.where = append(b.where, col+" = ?")
b.args = append(b.args, val)
return b
}
func (b *UpdateBuilder) HasSets() bool {
return len(b.sets) > 0
}
func (b *UpdateBuilder) Build() (string, []interface{}) {
q := fmt.Sprintf("UPDATE %s SET %s", b.table, strings.Join(b.sets, ", "))
if len(b.where) > 0 {
q += " WHERE " + strings.Join(b.where, " AND ")
}
return q, b.args
}
func (b *UpdateBuilder) Exec(db *sql.DB) (sql.Result, error) {
q, args := b.Build()
return db.Exec(q, args...)
}
// ── Select Builder ──────────────────────────
type SelectBuilder struct {
cols string
table string
joins []string
where []string
args []interface{}
orderBy string
limit int
offset int
}
func NewSelect(cols, table string) *SelectBuilder {
return &SelectBuilder{cols: cols, table: table}
}
func (b *SelectBuilder) Join(join string) *SelectBuilder {
b.joins = append(b.joins, join)
return b
}
func (b *SelectBuilder) Where(clause string, args ...interface{}) *SelectBuilder {
b.where = append(b.where, clause)
b.args = append(b.args, args...)
return b
}
func (b *SelectBuilder) WhereRaw(clause string) *SelectBuilder {
b.where = append(b.where, clause)
return b
}
func (b *SelectBuilder) OrderBy(col, order string) *SelectBuilder {
if order == "" {
order = "DESC"
}
b.orderBy = fmt.Sprintf("%s %s", col, strings.ToUpper(order))
return b
}
func (b *SelectBuilder) Paginate(opts store.ListOptions) *SelectBuilder {
if opts.Limit > 0 {
b.limit = opts.Limit
}
if opts.Offset > 0 {
b.offset = opts.Offset
}
if opts.Sort != "" {
b.OrderBy(opts.Sort, opts.Order)
}
return b
}
func (b *SelectBuilder) Build() (string, []interface{}) {
q := fmt.Sprintf("SELECT %s FROM %s", b.cols, b.table)
for _, j := range b.joins {
q += " " + j
}
if len(b.where) > 0 {
q += " WHERE " + strings.Join(b.where, " AND ")
}
if b.orderBy != "" {
q += " ORDER BY " + b.orderBy
}
if b.limit > 0 {
q += fmt.Sprintf(" LIMIT %d", b.limit)
}
if b.offset > 0 {
q += fmt.Sprintf(" OFFSET %d", b.offset)
}
return q, b.args
}
func (b *SelectBuilder) CountBuild() (string, []interface{}) {
q := fmt.Sprintf("SELECT COUNT(*) FROM %s", b.table)
for _, j := range b.joins {
q += " " + j
}
if len(b.where) > 0 {
q += " WHERE " + strings.Join(b.where, " AND ")
}
return q, b.args
}
// ── JSON Helpers ────────────────────────────
// ToJSON marshals a value to JSON string for TEXT columns.
func ToJSON(v interface{}) string {
if v == nil {
return "{}"
}
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}
// ScanJSON scans a TEXT column (JSON) into a target.
func ScanJSON(src interface{}, dst interface{}) error {
if src == nil {
return nil
}
var data []byte
switch v := src.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return fmt.Errorf("unsupported JSON type: %T", src)
}
return json.Unmarshal(data, dst)
}
// ArrayToJSON converts a []string to JSON text for storage.
func ArrayToJSON(arr []string) string {
if arr == nil {
return "[]"
}
b, _ := json.Marshal(arr)
return string(b)
}
// ScanArray scans a JSON text array column into []string.
func ScanArray(src interface{}) []string {
if src == nil {
return nil
}
var data string
switch v := src.(type) {
case string:
data = v
case []byte:
data = string(v)
default:
return nil
}
var arr []string
if err := json.Unmarshal([]byte(data), &arr); err != nil {
return nil
}
return arr
}
// ── Nullable Helpers ────────────────────────
func NullableString(ns sql.NullString) string {
if ns.Valid {
return ns.String
}
return ""
}
func NullableStringPtr(ns sql.NullString) *string {
if ns.Valid {
return &ns.String
}
return nil
}
func NullableFloat64Ptr(nf sql.NullFloat64) *float64 {
if nf.Valid {
return &nf.Float64
}
return nil
}
func NullableIntPtr(ni sql.NullInt64) *int {
if ni.Valid {
v := int(ni.Int64)
return &v
}
return nil
}
// ── UUID Generation ─────────────────────────
// NewID generates a new UUID string. Imported from the uuid package
// at the store level to keep the helper package dependency-light.
// Callers should use store.NewID() instead.

View File

@@ -0,0 +1,641 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── KnowledgeBaseStore ──────────────────────────
type KnowledgeBaseStore struct{}
func NewKnowledgeBaseStore() *KnowledgeBaseStore { return &KnowledgeBaseStore{} }
// ── KB CRUD ──────────────────────────────────────
func (s *KnowledgeBaseStore) Create(ctx context.Context, kb *models.KnowledgeBase) error {
kb.ID = store.NewID()
now := time.Now().UTC()
kb.CreatedAt = now
kb.UpdatedAt = now
// document_count, chunk_count, total_bytes default to 0 (Go zero values)
_, err := DB.ExecContext(ctx, `
INSERT INTO knowledge_bases (id, name, description, scope, owner_id, team_id, embedding_config, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
kb.ID, kb.Name, kb.Description, kb.Scope,
models.NullString(kb.OwnerID), models.NullString(kb.TeamID),
ToJSON(kb.EmbeddingConfig), kb.Status,
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *KnowledgeBaseStore) GetByID(ctx context.Context, id string) (*models.KnowledgeBase, error) {
kb, err := scanKB(DB.QueryRowContext(ctx, `
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
FROM knowledge_bases WHERE id = ?`, id))
if err != nil {
return nil, err
}
return kb, nil
}
func (s *KnowledgeBaseStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("knowledge_bases")
for k, v := range fields {
if k == "embedding_config" {
b.SetJSON(k, v)
} else {
b.Set(k, v)
}
}
b.SetExpr("updated_at", nowExpr)
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *KnowledgeBaseStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM knowledge_bases WHERE id = ?", id)
return err
}
// ── Scoped Listing ──────────────────────────────
func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
q := `
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
FROM knowledge_bases
WHERE scope = 'global'
OR (scope = 'personal' AND owner_id = ?)`
args := []interface{}{userID}
if len(teamIDs) > 0 {
placeholders := makeQPlaceholders(len(teamIDs))
q += fmt.Sprintf(` OR (scope = 'team' AND team_id IN (%s))`, placeholders)
for _, tid := range teamIDs {
args = append(args, tid)
}
}
// Group-granted KBs
q += `
OR id IN (
SELECT rg.resource_id FROM resource_grants rg
WHERE rg.resource_type = 'knowledge_base'
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
WHERE gm.user_id = ?
))
)
)`
args = append(args, userID)
q += ` ORDER BY name`
return queryKBs(ctx, q, args...)
}
func (s *KnowledgeBaseStore) ListGlobal(ctx context.Context) ([]models.KnowledgeBase, error) {
return queryKBs(ctx, `
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
FROM knowledge_bases WHERE scope = 'global' ORDER BY name`)
}
func (s *KnowledgeBaseStore) ListForTeam(ctx context.Context, teamID string) ([]models.KnowledgeBase, error) {
return queryKBs(ctx, `
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
FROM knowledge_bases WHERE team_id = ? ORDER BY name`, teamID)
}
func (s *KnowledgeBaseStore) ListPersonal(ctx context.Context, userID string) ([]models.KnowledgeBase, error) {
return queryKBs(ctx, `
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
FROM knowledge_bases WHERE scope = 'personal' AND owner_id = ? ORDER BY name`, userID)
}
// ── Documents ────────────────────────────────────
func (s *KnowledgeBaseStore) CreateDocument(ctx context.Context, doc *models.KBDocument) error {
doc.ID = store.NewID()
now := time.Now().UTC()
doc.CreatedAt = now
doc.UpdatedAt = now
// chunk_count defaults to 0 (Go zero value)
_, err := DB.ExecContext(ctx, `
INSERT INTO kb_documents (id, kb_id, filename, content_type, size_bytes, storage_key, status, uploaded_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
doc.ID, doc.KBID, doc.Filename, doc.ContentType, doc.SizeBytes,
doc.StorageKey, doc.Status, doc.UploadedBy,
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *KnowledgeBaseStore) GetDocument(ctx context.Context, id string) (*models.KBDocument, error) {
var doc models.KBDocument
var extractedText, errMsg sql.NullString
err := DB.QueryRowContext(ctx, `
SELECT id, kb_id, filename, content_type, size_bytes, storage_key,
extracted_text, chunk_count, status, error, uploaded_by, created_at, updated_at
FROM kb_documents WHERE id = ?`, id).Scan(
&doc.ID, &doc.KBID, &doc.Filename, &doc.ContentType, &doc.SizeBytes,
&doc.StorageKey, &extractedText, &doc.ChunkCount, &doc.Status,
&errMsg, &doc.UploadedBy, st(&doc.CreatedAt), st(&doc.UpdatedAt),
)
if err != nil {
return nil, err
}
doc.ExtractedText = NullableStringPtr(extractedText)
doc.Error = NullableStringPtr(errMsg)
return &doc, nil
}
func (s *KnowledgeBaseStore) ListDocuments(ctx context.Context, kbID string) ([]models.KBDocument, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, kb_id, filename, content_type, size_bytes, storage_key,
chunk_count, status, error, uploaded_by, created_at, updated_at
FROM kb_documents WHERE kb_id = ? ORDER BY created_at`, kbID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.KBDocument
for rows.Next() {
var doc models.KBDocument
var errMsg sql.NullString
err := rows.Scan(&doc.ID, &doc.KBID, &doc.Filename, &doc.ContentType,
&doc.SizeBytes, &doc.StorageKey, &doc.ChunkCount, &doc.Status,
&errMsg, &doc.UploadedBy, st(&doc.CreatedAt), st(&doc.UpdatedAt))
if err != nil {
return nil, err
}
doc.Error = NullableStringPtr(errMsg)
result = append(result, doc)
}
return result, rows.Err()
}
func (s *KnowledgeBaseStore) UpdateDocumentStatus(ctx context.Context, id string, status string, errMsg *string) error {
_, err := DB.ExecContext(ctx, `
UPDATE kb_documents SET status = ?, error = ?, updated_at = datetime('now')
WHERE id = ?`, status, models.NullString(errMsg), id)
return err
}
func (s *KnowledgeBaseStore) UpdateDocumentText(ctx context.Context, id string, text string, chunkCount int) error {
_, err := DB.ExecContext(ctx, `
UPDATE kb_documents SET extracted_text = ?, chunk_count = ?, updated_at = datetime('now')
WHERE id = ?`, text, chunkCount, id)
return err
}
func (s *KnowledgeBaseStore) UpdateDocumentStorageKey(ctx context.Context, id string, storageKey string) error {
_, err := DB.ExecContext(ctx, `
UPDATE kb_documents SET storage_key = ? WHERE id = ?`, storageKey, id)
return err
}
func (s *KnowledgeBaseStore) DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) {
var doc models.KBDocument
var errMsg sql.NullString
err := DB.QueryRowContext(ctx, `
DELETE FROM kb_documents WHERE id = ?
RETURNING id, kb_id, filename, storage_key, status, error`,
id).Scan(&doc.ID, &doc.KBID, &doc.Filename, &doc.StorageKey, &doc.Status, &errMsg)
if err != nil {
return nil, err
}
doc.Error = NullableStringPtr(errMsg)
return &doc, nil
}
// ── Chunks ───────────────────────────────────────
func (s *KnowledgeBaseStore) InsertChunks(ctx context.Context, chunks []models.KBChunk) error {
if len(chunks) == 0 {
return nil
}
// Batch insert using multi-row INSERT.
// Embeddings stored as JSON text (e.g. "[0.1,0.2,...]") for app-level cosine similarity.
valueParts := make([]string, 0, len(chunks))
args := make([]interface{}, 0, len(chunks)*8)
for _, c := range chunks {
c.ID = store.NewID()
valueParts = append(valueParts, "(?, ?, ?, ?, ?, ?, ?, ?)")
var embJSON interface{}
if len(c.Embedding) > 0 {
embJSON = ToJSON(c.Embedding)
}
args = append(args, c.ID, c.KBID, c.DocumentID, c.ChunkIndex,
c.Content, c.TokenCount, embJSON, ToJSON(c.Metadata))
}
q := fmt.Sprintf(`INSERT INTO kb_chunks (id, kb_id, document_id, chunk_index, content, token_count, embedding, metadata)
VALUES %s`, strings.Join(valueParts, ", "))
_, err := DB.ExecContext(ctx, q, args...)
return err
}
func (s *KnowledgeBaseStore) DeleteChunksForDocument(ctx context.Context, documentID string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM kb_chunks WHERE document_id = ?", documentID)
return err
}
// SimilaritySearch performs cosine similarity search in Go.
// Loads candidate chunks from the specified KBs, decodes their JSON-stored
// embeddings, computes cosine similarity against queryVec, and returns the
// top results above threshold. For SQLite-scale deployments (single-user,
// edge, dev) this is perfectly adequate without requiring CGO/sqlite-vec.
func (s *KnowledgeBaseStore) SimilaritySearch(ctx context.Context, kbIDs []string, queryVec []float64, threshold float64, limit int) ([]models.KBSearchResult, error) {
if len(kbIDs) == 0 || len(queryVec) == 0 {
return nil, nil
}
if limit <= 0 {
limit = 5
}
// Load candidate chunks with embeddings
placeholders := makeQPlaceholders(len(kbIDs))
q := fmt.Sprintf(`
SELECT c.content, c.embedding, c.metadata, d.filename, kb.name
FROM kb_chunks c
JOIN kb_documents d ON c.document_id = d.id
JOIN knowledge_bases kb ON c.kb_id = kb.id
WHERE c.kb_id IN (%s)
AND c.embedding IS NOT NULL`, placeholders)
args := make([]interface{}, len(kbIDs))
for i, id := range kbIDs {
args[i] = id
}
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
type candidate struct {
result models.KBSearchResult
similarity float64
}
var candidates []candidate
for rows.Next() {
var content, embJSON, filename, kbName string
var metaJSON sql.NullString
if err := rows.Scan(&content, &embJSON, &metaJSON, &filename, &kbName); err != nil {
return nil, err
}
// Decode embedding from JSON
var embedding []float64
if err := json.Unmarshal([]byte(embJSON), &embedding); err != nil {
continue // skip chunks with malformed embeddings
}
sim := cosineSimilarity(queryVec, embedding)
if sim > threshold {
r := models.KBSearchResult{
Content: content,
Filename: filename,
KBName: kbName,
Similarity: sim,
}
if metaJSON.Valid {
ScanJSON(metaJSON.String, &r.Metadata)
}
candidates = append(candidates, candidate{result: r, similarity: sim})
}
}
if err := rows.Err(); err != nil {
return nil, err
}
// Sort by similarity descending
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].similarity > candidates[j].similarity
})
// Cap at limit
if len(candidates) > limit {
candidates = candidates[:limit]
}
results := make([]models.KBSearchResult, len(candidates))
for i, c := range candidates {
results[i] = c.result
}
return results, nil
}
// cosineSimilarity computes cosine similarity between two vectors.
// Returns 0 if either vector is zero-length or all-zeros.
func cosineSimilarity(a, b []float64) float64 {
if len(a) != len(b) || len(a) == 0 {
return 0
}
var dot, normA, normB float64
for i := range a {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
if normA == 0 || normB == 0 {
return 0
}
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
}
// ── Channel Links ─────────────────────────────────
func (s *KnowledgeBaseStore) SetChannelKBs(ctx context.Context, channelID string, kbIDs []string) error {
tx, err := DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, "DELETE FROM channel_knowledge_bases WHERE channel_id = ?", channelID)
if err != nil {
return err
}
for _, kbID := range kbIDs {
_, err = tx.ExecContext(ctx, `
INSERT INTO channel_knowledge_bases (channel_id, kb_id, enabled) VALUES (?, ?, 1)`,
channelID, kbID)
if err != nil {
return err
}
}
return tx.Commit()
}
func (s *KnowledgeBaseStore) GetChannelKBs(ctx context.Context, channelID string) ([]models.ChannelKB, error) {
rows, err := DB.QueryContext(ctx, `
SELECT ckb.kb_id, kb.name, ckb.enabled, kb.document_count
FROM channel_knowledge_bases ckb
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
WHERE ckb.channel_id = ?
ORDER BY kb.name`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.ChannelKB
for rows.Next() {
var ckb models.ChannelKB
if err := rows.Scan(&ckb.KBID, &ckb.KBName, &ckb.Enabled, &ckb.DocumentCount); err != nil {
return nil, err
}
result = append(result, ckb)
}
return result, rows.Err()
}
func (s *KnowledgeBaseStore) GetActiveKBIDs(ctx context.Context, channelID string, userID string, teamIDs []string) ([]string, error) {
q := `
SELECT ckb.kb_id
FROM channel_knowledge_bases ckb
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
WHERE ckb.channel_id = ? AND ckb.enabled = 1
AND (
kb.scope = 'global'
OR (kb.scope = 'personal' AND kb.owner_id = ?)`
args := []interface{}{channelID, userID}
if len(teamIDs) > 0 {
placeholders := makeQPlaceholders(len(teamIDs))
q += fmt.Sprintf(` OR (kb.scope = 'team' AND kb.team_id IN (%s))`, placeholders)
for _, tid := range teamIDs {
args = append(args, tid)
}
}
q += `)`
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ── Stats ────────────────────────────────────────
func (s *KnowledgeBaseStore) UpdateStats(ctx context.Context, kbID string) error {
_, err := DB.ExecContext(ctx, `
UPDATE knowledge_bases SET
document_count = (SELECT COUNT(*) FROM kb_documents WHERE kb_id = ? AND status != 'error'),
chunk_count = (SELECT COUNT(*) FROM kb_chunks WHERE kb_id = ?),
total_bytes = COALESCE((SELECT SUM(size_bytes) FROM kb_documents WHERE kb_id = ?), 0),
updated_at = datetime('now')
WHERE id = ?`, kbID, kbID, kbID, kbID)
return err
}
// ── Discoverable Management (v0.17.0) ────────────
func (s *KnowledgeBaseStore) SetDiscoverable(ctx context.Context, kbID string, discoverable bool) error {
_, err := DB.ExecContext(ctx, `
UPDATE knowledge_bases SET discoverable = ?, updated_at = datetime('now')
WHERE id = ?`, discoverable, kbID)
return err
}
// GetActiveKBIDsWithPersona returns KB IDs accessible for a channel, including
// KBs bound to the active persona.
func (s *KnowledgeBaseStore) GetActiveKBIDsWithPersona(ctx context.Context, channelID string, userID string, teamIDs []string, personaID string) ([]string, error) {
q := `
SELECT ckb.kb_id
FROM channel_knowledge_bases ckb
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
WHERE ckb.channel_id = ? AND ckb.enabled = 1
AND (
kb.scope = 'global'
OR (kb.scope = 'personal' AND kb.owner_id = ?)`
args := []interface{}{channelID, userID}
if len(teamIDs) > 0 {
placeholders := makeQPlaceholders(len(teamIDs))
q += fmt.Sprintf(` OR (kb.scope = 'team' AND kb.team_id IN (%s))`, placeholders)
for _, tid := range teamIDs {
args = append(args, tid)
}
}
q += `)`
// UNION with persona-bound KBs (bypass discoverable check)
if personaID != "" {
q += `
UNION
SELECT pkb.kb_id
FROM persona_knowledge_bases pkb
JOIN knowledge_bases kb ON pkb.kb_id = kb.id
WHERE pkb.persona_id = ?
AND kb.chunk_count > 0`
args = append(args, personaID)
}
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
seen := make(map[string]bool)
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
return ids, rows.Err()
}
// ListDiscoverable returns KBs the user can see AND that are discoverable.
func (s *KnowledgeBaseStore) ListDiscoverable(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
q := `
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
FROM knowledge_bases
WHERE discoverable = 1
AND (
scope = 'global'
OR (scope = 'personal' AND owner_id = ?)`
args := []interface{}{userID}
if len(teamIDs) > 0 {
placeholders := makeQPlaceholders(len(teamIDs))
q += fmt.Sprintf(` OR (scope = 'team' AND team_id IN (%s))`, placeholders)
for _, tid := range teamIDs {
args = append(args, tid)
}
}
// Group-granted KBs
q += `
OR id IN (
SELECT rg.resource_id FROM resource_grants rg
WHERE rg.resource_type = 'knowledge_base'
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
WHERE gm.user_id = ?
))
)
)`
args = append(args, userID)
q += `) ORDER BY name`
return queryKBs(ctx, q, args...)
}
// ── Scan Helpers ─────────────────────────────────
func scanKB(row *sql.Row) (*models.KnowledgeBase, error) {
var kb models.KnowledgeBase
var ownerID, teamID sql.NullString
var embCfgJSON string
err := row.Scan(
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
&ownerID, &teamID, &embCfgJSON,
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
&kb.Status, &kb.Discoverable, st(&kb.CreatedAt), st(&kb.UpdatedAt),
)
if err != nil {
return nil, err
}
kb.OwnerID = NullableStringPtr(ownerID)
kb.TeamID = NullableStringPtr(teamID)
ScanJSON(embCfgJSON, &kb.EmbeddingConfig)
return &kb, nil
}
func queryKBs(ctx context.Context, query string, args ...interface{}) ([]models.KnowledgeBase, error) {
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.KnowledgeBase
for rows.Next() {
var kb models.KnowledgeBase
var ownerID, teamID sql.NullString
var embCfgJSON string
err := rows.Scan(
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
&ownerID, &teamID, &embCfgJSON,
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
&kb.Status, &kb.Discoverable, st(&kb.CreatedAt), st(&kb.UpdatedAt),
)
if err != nil {
return nil, err
}
kb.OwnerID = NullableStringPtr(ownerID)
kb.TeamID = NullableStringPtr(teamID)
ScanJSON(embCfgJSON, &kb.EmbeddingConfig)
result = append(result, kb)
}
return result, rows.Err()
}
// makeQPlaceholders returns "?,?,?" for n items.
func makeQPlaceholders(n int) string {
if n <= 0 {
return ""
}
return strings.TrimRight(strings.Repeat("?,", n), ",")
}

View File

@@ -0,0 +1,190 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/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)
m.ID = store.NewID()
m.CreatedAt = time.Now().UTC()
_, err := DB.ExecContext(ctx, `
INSERT INTO messages (id, channel_id, role, content, model, tokens_used, tool_calls,
metadata, parent_id, sibling_index, participant_type, participant_id, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
m.ID, m.ChannelID, m.Role, m.Content, m.Model, m.TokensUsed,
toolCallsJSON, metadataJSON,
models.NullString(m.ParentID), m.SiblingIndex,
m.ParticipantType, m.ParticipantID,
m.CreatedAt.Format(timeFmt),
)
return err
}
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 *time.Time
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 = ?`, id).Scan(
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
&toolCallsJSON, &metadataJSON,
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
stN(&deletedAt), st(&m.CreatedAt),
)
if err != nil {
return nil, err
}
m.ParentID = NullableStringPtr(parentID)
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
json.Unmarshal(metadataJSON, &m.Metadata)
m.DeletedAt = deletedAt
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 = ? WHERE id = ?", 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 = ? 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 = ?)
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 = ?
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 * 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 = ? 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 = ? 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 *time.Time
err := rows.Scan(
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
&toolCallsJSON, &metadataJSON,
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
stN(&deletedAt), st(&m.CreatedAt),
)
if err != nil {
return nil, err
}
m.ParentID = NullableStringPtr(parentID)
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
json.Unmarshal(metadataJSON, &m.Metadata)
m.DeletedAt = deletedAt
result = append(result, m)
}
return result, rows.Err()
}

199
server/store/sqlite/note.go Normal file
View File

@@ -0,0 +1,199 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type NoteStore struct{}
func NewNoteStore() *NoteStore { return &NoteStore{} }
func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
n.ID = store.NewID()
now := time.Now().UTC()
n.CreatedAt = now
n.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO notes (id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
n.ID, n.UserID, n.Title, n.Content, n.FolderPath,
ArrayToJSON(n.Tags), ToJSON(n.Metadata),
models.NullString(n.SourceChannelID), models.NullString(n.TeamID),
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) {
var n models.Note
var sourceChannelID, teamID sql.NullString
var tagsJSON, metadataJSON string
err := DB.QueryRowContext(ctx, `
SELECT id, user_id, title, content, folder_path, tags, metadata,
source_channel_id, team_id, created_at, updated_at
FROM notes WHERE id = ?`, id).Scan(
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
&tagsJSON, &metadataJSON,
&sourceChannelID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt),
)
if err != nil {
return nil, err
}
n.Tags = ScanArray(tagsJSON)
n.SourceChannelID = NullableStringPtr(sourceChannelID)
n.TeamID = NullableStringPtr(teamID)
json.Unmarshal([]byte(metadataJSON), &n.Metadata)
return &n, nil
}
func (s *NoteStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("notes")
for k, v := range fields {
if k == "metadata" {
b.SetJSON(k, v)
} else if k == "tags" {
if tags, ok := v.([]string); ok {
b.Set(k, ArrayToJSON(tags))
}
} else {
b.Set(k, v)
}
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *NoteStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM notes WHERE id = ?", id)
return err
}
func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.NoteListOptions) ([]models.Note, int, error) {
b := NewSelect(
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
"notes",
).Where("user_id = ?", userID)
if opts.FolderPath != "" {
b.Where("folder_path = ?", opts.FolderPath)
}
if opts.Tag != "" {
// SQLite: check JSON array membership with json_each.
b.Where("EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)", opts.Tag)
}
if opts.TeamID != "" {
b.Where("team_id = ?", opts.TeamID)
}
// Count
countQ, countArgs := b.CountBuild()
var total int
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
if opts.Sort == "" {
b.OrderBy("updated_at", "DESC")
}
b.Paginate(opts.ListOptions)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
return s.scanNotes(rows, total)
}
// Search uses LIKE against title and content (SQLite fallback for tsvector).
func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Note, int, error) {
// Split query into words, build LIKE clauses for each.
words := strings.Fields(query)
if len(words) == 0 {
return nil, 0, nil
}
b := NewSelect(
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
"notes",
).Where("user_id = ?", userID)
for _, w := range words {
pattern := "%" + w + "%"
b.Where("(title LIKE ? OR content LIKE ?)", pattern, pattern)
}
// Count
countQ, countArgs := b.CountBuild()
var total int
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
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()
return s.scanNotes(rows, total)
}
func (s *NoteStore) BulkDelete(ctx context.Context, ids []string, userID string) (int, error) {
if len(ids) == 0 {
return 0, nil
}
placeholders := make([]string, len(ids))
args := make([]interface{}, 0, len(ids)+1)
args = append(args, userID)
for i, id := range ids {
placeholders[i] = "?"
args = append(args, id)
}
result, err := DB.ExecContext(ctx,
fmt.Sprintf("DELETE FROM notes WHERE user_id = ? AND id IN (%s)",
strings.Join(placeholders, ",")),
args...)
if err != nil {
return 0, err
}
n, _ := result.RowsAffected()
return int(n), nil
}
// ── helpers ─────────────────────────────────
func (s *NoteStore) scanNotes(rows *sql.Rows, total int) ([]models.Note, int, error) {
var result []models.Note
for rows.Next() {
var n models.Note
var sourceChannelID, teamID sql.NullString
var tagsJSON, metadataJSON string
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
&tagsJSON, &metadataJSON,
&sourceChannelID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt))
if err != nil {
return nil, 0, err
}
n.Tags = ScanArray(tagsJSON)
n.SourceChannelID = NullableStringPtr(sourceChannelID)
n.TeamID = NullableStringPtr(teamID)
json.Unmarshal([]byte(metadataJSON), &n.Metadata)
result = append(result, n)
}
return result, total, rows.Err()
}

View File

@@ -0,0 +1,410 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type PersonaStore struct{}
func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
const personaCols = `id, name, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared, created_at, updated_at`
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
p.ID = store.NewID()
now := time.Now().UTC()
p.CreatedAt = now
p.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO personas (id, name, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.ID, p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID,
models.NullString(p.ProviderConfigID),
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
p.Scope, models.NullString(p.OwnerID), p.CreatedBy, p.IsActive, p.IsShared,
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *PersonaStore) GetByID(ctx context.Context, id string) (*models.Persona, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE id = ?", personaCols), id)
p, err := scanPersona(row)
if err != nil {
return nil, err
}
// Load grants
grants, _ := s.GetGrants(ctx, id)
p.Grants = grants
return p, nil
}
func (s *PersonaStore) Update(ctx context.Context, id string, patch models.PersonaPatch) error {
b := NewUpdate("personas")
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.Description != nil {
b.Set("description", *patch.Description)
}
if patch.Icon != nil {
b.Set("icon", *patch.Icon)
}
if patch.Avatar != nil {
b.Set("avatar", *patch.Avatar)
}
if patch.BaseModelID != nil {
b.Set("base_model_id", *patch.BaseModelID)
}
if patch.ProviderConfigID != nil {
b.Set("provider_config_id", models.NullString(patch.ProviderConfigID))
}
if patch.SystemPrompt != nil {
b.Set("system_prompt", *patch.SystemPrompt)
}
if patch.Temperature != nil {
b.Set("temperature", models.NullFloat(patch.Temperature))
}
if patch.MaxTokens != nil {
b.Set("max_tokens", models.NullInt(patch.MaxTokens))
}
if patch.ThinkingBudget != nil {
b.Set("thinking_budget", models.NullInt(patch.ThinkingBudget))
}
if patch.TopP != nil {
b.Set("top_p", models.NullFloat(patch.TopP))
}
if patch.IsActive != nil {
b.Set("is_active", *patch.IsActive)
}
if patch.IsShared != nil {
b.Set("is_shared", *patch.IsShared)
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *PersonaStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM personas WHERE id = ?", id)
return err
}
// ListForUser returns all Personas visible to a user:
// global active + team-scoped (for user's teams) + personal + shared + group-granted.
func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf(`SELECT %s FROM personas WHERE is_active = 1 AND (
scope = 'global'
OR (scope = 'personal' AND created_by = ?)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = ?
))
OR (scope = 'personal' AND is_shared = 1)
OR id IN (
SELECT rg.resource_id FROM resource_grants rg
WHERE rg.resource_type = 'persona'
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
WHERE gm.user_id = ?
))
)
)
) ORDER BY scope, name`, personaCols), userID, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
func (s *PersonaStore) ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'team' AND owner_id = ? AND is_active = 1 ORDER BY name", personaCols),
teamID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
func (s *PersonaStore) ListGlobal(ctx context.Context) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'global' ORDER BY name", personaCols))
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
func (s *PersonaStore) ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'personal' AND created_by = ? ORDER BY name", personaCols),
userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
// ── Grants ──────────────────────────────────
// SetGrants replaces all grants for a Persona (delete + re-insert).
func (s *PersonaStore) SetGrants(ctx context.Context, personaID string, grants []models.Grant) error {
tx, err := DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, "DELETE FROM persona_grants WHERE persona_id = ?", personaID)
if err != nil {
return err
}
for _, g := range grants {
configJSON := ToJSON(g.Config)
_, err = tx.ExecContext(ctx, `
INSERT INTO persona_grants (id, persona_id, grant_type, grant_ref, config)
VALUES (?, ?, ?, ?, ?)`,
store.NewID(), personaID, g.GrantType, g.GrantRef, configJSON)
if err != nil {
return fmt.Errorf("grant %s/%s: %w", g.GrantType, g.GrantRef, err)
}
}
return tx.Commit()
}
func (s *PersonaStore) GetGrants(ctx context.Context, personaID string) ([]models.Grant, error) {
rows, err := DB.QueryContext(ctx,
"SELECT id, persona_id, grant_type, grant_ref, config, created_at FROM persona_grants WHERE persona_id = ? ORDER BY grant_type, grant_ref",
personaID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Grant
for rows.Next() {
var g models.Grant
var configJSON []byte
err := rows.Scan(&g.ID, &g.PersonaID, &g.GrantType, &g.GrantRef, &configJSON, st(&g.CreatedAt))
if err != nil {
return nil, err
}
json.Unmarshal(configJSON, &g.Config)
result = append(result, g)
}
return result, rows.Err()
}
// GetToolGrants returns just the tool names for a Persona.
func (s *PersonaStore) GetToolGrants(ctx context.Context, personaID string) ([]string, error) {
rows, err := DB.QueryContext(ctx,
"SELECT grant_ref FROM persona_grants WHERE persona_id = ? AND grant_type = 'tool' ORDER BY grant_ref",
personaID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
result = append(result, name)
}
return result, rows.Err()
}
// UserCanAccess checks if a user can see/use a specific Persona.
func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM personas WHERE id = ? AND is_active = 1 AND (
scope = 'global'
OR (scope = 'personal' AND created_by = ?)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = ?
))
OR (scope = 'personal' AND is_shared = 1)
OR id IN (
SELECT rg.resource_id FROM resource_grants rg
WHERE rg.resource_type = 'persona'
AND rg.resource_id = ?
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
WHERE gm.user_id = ?
))
)
)
)
)`, personaID, userID, userID, personaID, userID).Scan(&exists)
return exists, err
}
// ── Scanners ────────────────────────────────
func scanPersona(row *sql.Row) (*models.Persona, error) {
var p models.Persona
var providerConfigID, ownerID sql.NullString
var temp, topP sql.NullFloat64
var maxTokens, thinkingBudget sql.NullInt64
err := row.Scan(
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
&p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
st(&p.CreatedAt), st(&p.UpdatedAt),
)
if err != nil {
return nil, err
}
p.ProviderConfigID = NullableStringPtr(providerConfigID)
p.OwnerID = NullableStringPtr(ownerID)
p.Temperature = NullableFloat64Ptr(temp)
p.MaxTokens = NullableIntPtr(maxTokens)
p.ThinkingBudget = NullableIntPtr(thinkingBudget)
p.TopP = NullableFloat64Ptr(topP)
return &p, nil
}
func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
var result []models.Persona
for rows.Next() {
var p models.Persona
var providerConfigID, ownerID sql.NullString
var temp, topP sql.NullFloat64
var maxTokens, thinkingBudget sql.NullInt64
err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
&p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
st(&p.CreatedAt), st(&p.UpdatedAt),
)
if err != nil {
return nil, err
}
p.ProviderConfigID = NullableStringPtr(providerConfigID)
p.OwnerID = NullableStringPtr(ownerID)
p.Temperature = NullableFloat64Ptr(temp)
p.MaxTokens = NullableIntPtr(maxTokens)
p.ThinkingBudget = NullableIntPtr(thinkingBudget)
p.TopP = NullableFloat64Ptr(topP)
result = append(result, p)
}
return result, rows.Err()
}
// ── Persona-KB Bindings (v0.17.0) ───────────
func (s *PersonaStore) SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error {
tx, err := DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, "DELETE FROM persona_knowledge_bases WHERE persona_id = ?", personaID)
if err != nil {
return err
}
for _, kbID := range kbIDs {
auto := false
if autoSearch != nil {
auto = autoSearch[kbID]
}
_, err = tx.ExecContext(ctx, `
INSERT INTO persona_knowledge_bases (persona_id, kb_id, auto_search)
VALUES (?, ?, ?)`,
personaID, kbID, auto)
if err != nil {
return fmt.Errorf("persona KB %s: %w", kbID, err)
}
}
return tx.Commit()
}
func (s *PersonaStore) GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error) {
rows, err := DB.QueryContext(ctx, `
SELECT pkb.persona_id, pkb.kb_id, pkb.auto_search, pkb.added_at,
kb.name AS kb_name, kb.document_count, kb.chunk_count
FROM persona_knowledge_bases pkb
JOIN knowledge_bases kb ON kb.id = pkb.kb_id
WHERE pkb.persona_id = ?
ORDER BY kb.name`, personaID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.PersonaKB
for rows.Next() {
var pkb models.PersonaKB
if err := rows.Scan(&pkb.PersonaID, &pkb.KBID, &pkb.AutoSearch, st(&pkb.AddedAt),
&pkb.KBName, &pkb.DocumentCount, &pkb.ChunkCount); err != nil {
return nil, err
}
result = append(result, pkb)
}
if result == nil {
result = make([]models.PersonaKB, 0)
}
return result, rows.Err()
}
func (s *PersonaStore) GetKBIDs(ctx context.Context, personaID string) ([]string, error) {
rows, err := DB.QueryContext(ctx,
"SELECT kb_id FROM persona_knowledge_bases WHERE persona_id = ?", personaID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
if ids == nil {
ids = make([]string, 0)
}
return ids, rows.Err()
}

View File

@@ -0,0 +1,62 @@
package sqlite
import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type PolicyStore struct{}
func NewPolicyStore() *PolicyStore { return &PolicyStore{} }
func (s *PolicyStore) Get(ctx context.Context, key string) (string, error) {
var value string
err := DB.QueryRowContext(ctx,
"SELECT value FROM platform_policies WHERE key = ?", key).Scan(&value)
if err == sql.ErrNoRows {
if def, ok := models.PolicyDefaults[key]; ok {
return def, nil
}
return "", nil
}
return value, err
}
func (s *PolicyStore) GetBool(ctx context.Context, key string) (bool, error) {
val, err := s.Get(ctx, key)
if err != nil {
return false, err
}
return val == "true", nil
}
func (s *PolicyStore) Set(ctx context.Context, key, value, updatedBy string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO platform_policies (key, value, updated_by, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by, updated_at = datetime('now')`,
key, value, updatedBy, value, updatedBy)
return err
}
func (s *PolicyStore) GetAll(ctx context.Context) (map[string]string, error) {
result := make(map[string]string)
for k, v := range models.PolicyDefaults {
result[k] = v
}
rows, err := DB.QueryContext(ctx, "SELECT key, value FROM platform_policies")
if err != nil {
return result, err
}
defer rows.Close()
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
continue
}
result[k] = v
}
return result, rows.Err()
}

View File

@@ -0,0 +1,132 @@
package sqlite
import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type PricingStore struct{}
func NewPricingStore() *PricingStore { return &PricingStore{} }
// GetForModel returns the pricing entry for a specific provider+model pair.
func (s *PricingStore) GetForModel(ctx context.Context, providerConfigID, modelID string) (*models.PricingEntry, error) {
var p models.PricingEntry
err := DB.QueryRowContext(ctx, `
SELECT id, provider_config_id, model_id,
input_per_m, output_per_m, cache_create_per_m, cache_read_per_m,
currency, source, updated_at, updated_by
FROM model_pricing
WHERE provider_config_id = ? AND model_id = ?
`, providerConfigID, modelID).Scan(
&p.ID, &p.ProviderConfigID, &p.ModelID,
&p.InputPerM, &p.OutputPerM, &p.CacheCreatePerM, &p.CacheReadPerM,
&p.Currency, &p.Source, st(&p.UpdatedAt), &p.UpdatedBy,
)
if err == sql.ErrNoRows {
return nil, nil
}
return &p, err
}
// Upsert inserts or updates a pricing entry (manual admin override).
func (s *PricingStore) Upsert(ctx context.Context, entry *models.PricingEntry) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO model_pricing (
id, provider_config_id, model_id,
input_per_m, output_per_m, cache_create_per_m, cache_read_per_m,
currency, source, updated_by, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT (provider_config_id, model_id)
DO UPDATE SET
input_per_m = EXCLUDED.input_per_m,
output_per_m = EXCLUDED.output_per_m,
cache_create_per_m = EXCLUDED.cache_create_per_m,
cache_read_per_m = EXCLUDED.cache_read_per_m,
currency = EXCLUDED.currency,
source = EXCLUDED.source,
updated_by = EXCLUDED.updated_by,
updated_at = datetime('now')
`,
store.NewID(), entry.ProviderConfigID, entry.ModelID,
entry.InputPerM, entry.OutputPerM, entry.CacheCreatePerM, entry.CacheReadPerM,
entry.Currency, entry.Source, entry.UpdatedBy,
)
return err
}
// UpsertFromCatalog inserts or updates pricing from a catalog sync.
// Manual overrides (source='manual') are never overwritten.
func (s *PricingStore) UpsertFromCatalog(ctx context.Context, providerConfigID, modelID string, pricing *models.ModelPricing) error {
if pricing == nil || (pricing.InputPerM == 0 && pricing.OutputPerM == 0) {
return nil // No pricing to store
}
currency := pricing.Currency
if currency == "" {
currency = "USD"
}
_, err := DB.ExecContext(ctx, `
INSERT INTO model_pricing (
id, provider_config_id, model_id,
input_per_m, output_per_m,
currency, source, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'catalog', datetime('now'))
ON CONFLICT (provider_config_id, model_id)
DO UPDATE SET
input_per_m = EXCLUDED.input_per_m,
output_per_m = EXCLUDED.output_per_m,
currency = EXCLUDED.currency,
updated_at = datetime('now')
WHERE model_pricing.source = 'catalog'
`,
store.NewID(), providerConfigID, modelID,
pricing.InputPerM, pricing.OutputPerM,
currency,
)
return err
}
// List returns pricing entries for admin-managed providers (global + team).
// Excludes personal BYOK providers — those are not the admin's concern.
func (s *PricingStore) List(ctx context.Context) ([]models.PricingEntry, error) {
rows, err := DB.QueryContext(ctx, `
SELECT mp.id, mp.provider_config_id, mp.model_id,
mp.input_per_m, mp.output_per_m, mp.cache_create_per_m, mp.cache_read_per_m,
mp.currency, mp.source, mp.updated_at, mp.updated_by
FROM model_pricing mp
JOIN provider_configs pc ON pc.id = mp.provider_config_id
WHERE pc.scope != 'personal'
ORDER BY mp.provider_config_id, mp.model_id
`)
if err != nil {
return nil, err
}
defer rows.Close()
var results []models.PricingEntry
for rows.Next() {
var p models.PricingEntry
if err := rows.Scan(
&p.ID, &p.ProviderConfigID, &p.ModelID,
&p.InputPerM, &p.OutputPerM, &p.CacheCreatePerM, &p.CacheReadPerM,
&p.Currency, &p.Source, st(&p.UpdatedAt), &p.UpdatedBy,
); err != nil {
return nil, err
}
results = append(results, p)
}
return results, rows.Err()
}
// Delete removes a pricing entry.
func (s *PricingStore) Delete(ctx context.Context, providerConfigID, modelID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM model_pricing WHERE provider_config_id = ? AND model_id = ?
`, providerConfigID, modelID)
return err
}

View File

@@ -0,0 +1,205 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type ProviderStore struct{}
func NewProviderStore() *ProviderStore { return &ProviderStore{} }
const providerCols = `id, scope, owner_id, name, provider, endpoint, api_key_enc,
key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private, created_at, updated_at`
func (s *ProviderStore) Create(ctx context.Context, cfg *models.ProviderConfig) error {
cfg.ID = store.NewID()
now := time.Now().UTC()
cfg.CreatedAt = now
cfg.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO provider_configs (id, scope, owner_id, name, provider, endpoint, api_key_enc,
key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
cfg.ID, cfg.Scope, models.NullString(cfg.OwnerID), cfg.Name, cfg.Provider, cfg.Endpoint,
cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, cfg.ModelDefault,
ToJSON(cfg.Config), ToJSON(cfg.Headers), ToJSON(cfg.Settings),
cfg.IsActive, cfg.IsPrivate,
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *ProviderStore) GetByID(ctx context.Context, id string) (*models.ProviderConfig, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM provider_configs WHERE id = ?", providerCols), id)
return scanProvider(row)
}
func (s *ProviderStore) Update(ctx context.Context, id string, patch models.ProviderConfigPatch) error {
b := NewUpdate("provider_configs")
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.Endpoint != nil {
b.Set("endpoint", *patch.Endpoint)
}
if patch.APIKeyEnc != nil {
b.Set("api_key_enc", patch.APIKeyEnc)
b.Set("key_nonce", patch.KeyNonce)
}
if patch.ModelDefault != nil {
b.Set("model_default", *patch.ModelDefault)
}
if patch.Config != nil {
b.SetJSON("config", patch.Config)
}
if patch.Headers != nil {
b.SetJSON("headers", patch.Headers)
}
if patch.Settings != nil {
b.SetJSON("settings", patch.Settings)
}
if patch.IsActive != nil {
b.Set("is_active", *patch.IsActive)
}
if patch.IsPrivate != nil {
b.Set("is_private", *patch.IsPrivate)
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *ProviderStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM provider_configs WHERE id = ?", id)
return err
}
func (s *ProviderStore) ListGlobal(ctx context.Context) ([]models.ProviderConfig, error) {
return s.listByScope(ctx, models.ScopeGlobal, "")
}
func (s *ProviderStore) ListForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) {
return s.listByScope(ctx, models.ScopeTeam, teamID)
}
func (s *ProviderStore) ListForUser(ctx context.Context, userID string) ([]models.ProviderConfig, error) {
return s.listByScope(ctx, models.ScopePersonal, userID)
}
// ListAccessible returns all provider configs a user can access:
// global + team (for teams they belong to) + personal.
func (s *ProviderStore) ListAccessible(ctx context.Context, userID string) ([]models.ProviderConfig, error) {
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
SELECT %s FROM provider_configs
WHERE is_active = 1 AND (
scope = 'global'
OR (scope = 'personal' AND owner_id = ?)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = ?
))
)
ORDER BY scope, name`, providerCols), userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProviders(rows)
}
// UserCanAccess checks if a user can use a specific provider config.
func (s *ProviderStore) UserCanAccess(ctx context.Context, userID, configID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM provider_configs
WHERE id = ? AND is_active = 1 AND (
scope = 'global'
OR (scope = 'personal' AND owner_id = ?)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = ?
))
)
)`, configID, userID, userID).Scan(&exists)
return exists, err
}
// ── Internal helpers ────────────────────────
func (s *ProviderStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ProviderConfig, error) {
var rows *sql.Rows
var err error
if scope == models.ScopeGlobal {
rows, err = DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = ? AND is_active = 1 ORDER BY name", providerCols),
scope)
} else {
rows, err = DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = ? AND owner_id = ? AND is_active = 1 ORDER BY name", providerCols),
scope, ownerID)
}
if err != nil {
return nil, err
}
defer rows.Close()
return scanProviders(rows)
}
func scanProvider(row *sql.Row) (*models.ProviderConfig, error) {
var p models.ProviderConfig
var ownerID, modelDefault, keyScope sql.NullString
var configJSON, headersJSON, settingsJSON []byte
err := row.Scan(
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
&p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault,
&configJSON, &headersJSON, &settingsJSON,
&p.IsActive, &p.IsPrivate, st(&p.CreatedAt), st(&p.UpdatedAt),
)
if err != nil {
return nil, err
}
p.OwnerID = NullableStringPtr(ownerID)
p.ModelDefault = modelDefault.String
p.KeyScope = keyScope.String
json.Unmarshal(configJSON, &p.Config)
json.Unmarshal(headersJSON, &p.Headers)
json.Unmarshal(settingsJSON, &p.Settings)
return &p, nil
}
func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) {
var result []models.ProviderConfig
for rows.Next() {
var p models.ProviderConfig
var ownerID, modelDefault, keyScope sql.NullString
var configJSON, headersJSON, settingsJSON []byte
err := rows.Scan(
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
&p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault,
&configJSON, &headersJSON, &settingsJSON,
&p.IsActive, &p.IsPrivate, st(&p.CreatedAt), st(&p.UpdatedAt),
)
if err != nil {
return nil, err
}
p.OwnerID = NullableStringPtr(ownerID)
p.ModelDefault = modelDefault.String
p.KeyScope = keyScope.String
json.Unmarshal(configJSON, &p.Config)
json.Unmarshal(headersJSON, &p.Headers)
json.Unmarshal(settingsJSON, &p.Settings)
result = append(result, p)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,106 @@
package sqlite
import (
"context"
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── ResourceGrantStore ──────────────────────
type ResourceGrantStore struct{}
func NewResourceGrantStore() *ResourceGrantStore { return &ResourceGrantStore{} }
// Set creates or replaces the grant for a resource (upsert on unique resource_type+resource_id).
func (s *ResourceGrantStore) Set(ctx context.Context, grant *models.ResourceGrant) error {
groups := grant.GrantedGroups
if groups == nil {
groups = []string{}
}
grant.ID = store.NewID()
now := time.Now().UTC()
_, err := DB.ExecContext(ctx, `
INSERT INTO resource_grants (id, resource_type, resource_id, grant_scope, granted_groups, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (resource_type, resource_id) DO UPDATE SET
grant_scope = excluded.grant_scope,
granted_groups = excluded.granted_groups,
created_by = excluded.created_by,
updated_at = excluded.updated_at`,
grant.ID, grant.ResourceType, grant.ResourceID, grant.GrantScope,
ArrayToJSON(groups), grant.CreatedBy,
now.Format(timeFmt), now.Format(timeFmt),
)
if err != nil {
return err
}
// Fetch actual ID + timestamps (upsert may have returned existing row)
grant.CreatedAt = now
grant.UpdatedAt = now
return DB.QueryRowContext(ctx,
`SELECT id FROM resource_grants WHERE resource_type = ? AND resource_id = ?`,
grant.ResourceType, grant.ResourceID,
).Scan(&grant.ID)
}
// Get retrieves the grant for a specific resource.
func (s *ResourceGrantStore) Get(ctx context.Context, resourceType, resourceID string) (*models.ResourceGrant, error) {
var rg models.ResourceGrant
var groupsJSON string
err := DB.QueryRowContext(ctx, `
SELECT id, resource_type, resource_id, grant_scope, granted_groups, created_by,
created_at, updated_at
FROM resource_grants
WHERE resource_type = ? AND resource_id = ?`,
resourceType, resourceID,
).Scan(&rg.ID, &rg.ResourceType, &rg.ResourceID, &rg.GrantScope,
&groupsJSON, &rg.CreatedBy,
st(&rg.CreatedAt), st(&rg.UpdatedAt))
if err != nil {
return nil, err
}
rg.GrantedGroups = ScanArray(groupsJSON)
return &rg, nil
}
// Delete removes the grant for a resource.
func (s *ResourceGrantStore) Delete(ctx context.Context, resourceType, resourceID string) error {
res, err := DB.ExecContext(ctx,
"DELETE FROM resource_grants WHERE resource_type = ? AND resource_id = ?",
resourceType, resourceID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return sql.ErrNoRows
}
return nil
}
// UserHasGroupAccess checks whether a user has group-based access to a resource.
// SQLite: uses json_each() to expand the JSON array for group membership checks.
func (s *ResourceGrantStore) UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error) {
var has bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM resource_grants rg
WHERE rg.resource_type = ?
AND rg.resource_id = ?
AND (
rg.grant_scope = 'global'
OR (
rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
JOIN json_each(rg.granted_groups) je ON je.value = gm.group_id
WHERE gm.user_id = ?
)
)
)
)`, resourceType, resourceID, userID).Scan(&has)
return has, err
}

View File

@@ -0,0 +1,34 @@
package sqlite
import (
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// NewStores creates all SQLite store implementations and wires them
// into the Stores bundle. Call this at startup after database.Connect().
func NewStores(db *sql.DB) store.Stores {
SetDB(db)
return store.Stores{
Providers: NewProviderStore(),
Catalog: NewCatalogStore(),
Personas: NewPersonaStore(),
Policies: NewPolicyStore(),
UserSettings: NewUserModelSettingsStore(),
Users: NewUserStore(),
Teams: NewTeamStore(),
Channels: NewChannelStore(),
Messages: NewMessageStore(),
Audit: NewAuditStore(),
Notes: NewNoteStore(),
GlobalConfig: NewGlobalConfigStore(),
Usage: NewUsageStore(),
Pricing: NewPricingStore(),
Extensions: NewExtensionStore(),
Attachments: NewAttachmentStore(),
KnowledgeBases: NewKnowledgeBaseStore(),
Groups: NewGroupStore(),
ResourceGrants: NewResourceGrantStore(),
}
}

205
server/store/sqlite/team.go Normal file
View File

@@ -0,0 +1,205 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type TeamStore struct{}
func NewTeamStore() *TeamStore { return &TeamStore{} }
func (s *TeamStore) Create(ctx context.Context, t *models.Team) error {
settingsJSON := ToJSON(t.Settings)
t.ID = store.NewID()
now := time.Now().UTC()
t.CreatedAt = now
t.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO teams (id, name, description, created_by, is_active, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Description, t.CreatedBy, t.IsActive, settingsJSON,
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *TeamStore) GetByID(ctx context.Context, id string) (*models.Team, error) {
var t models.Team
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams WHERE id = ?`, id).Scan(
&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MemberCount,
)
if err != nil {
return nil, err
}
json.Unmarshal(settingsJSON, &t.Settings)
return &t, nil
}
func (s *TeamStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("teams")
for k, v := range fields {
if k == "settings" {
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 *TeamStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM teams WHERE id = ?", id)
return err
}
func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Team
for rows.Next() {
var t models.Team
var settingsJSON []byte
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MemberCount)
if err != nil {
return nil, err
}
json.Unmarshal(settingsJSON, &t.Settings)
result = append(result, t)
}
return result, rows.Err()
}
// ── Members ─────────────────────────────────
func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO team_members (id, team_id, user_id, role) VALUES (?, ?, ?, ?)
ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role`,
store.NewID(), teamID, userID, role)
return err
}
func (s *TeamStore) RemoveMember(ctx context.Context, teamID, userID string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM team_members WHERE team_id = ? AND user_id = ?",
teamID, userID)
return err
}
func (s *TeamStore) UpdateMemberRole(ctx context.Context, teamID, userID, role string) error {
_, err := DB.ExecContext(ctx,
"UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?",
role, teamID, userID)
return err
}
func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) {
rows, err := DB.QueryContext(ctx, `
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = ?
ORDER BY tm.role DESC, u.username`, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.TeamMember
for rows.Next() {
var m models.TeamMember
err := rows.Scan(&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
if err != nil {
return nil, err
}
result = append(result, m)
}
return result, rows.Err()
}
func (s *TeamStore) GetMember(ctx context.Context, teamID, userID string) (*models.TeamMember, error) {
var m models.TeamMember
err := DB.QueryRowContext(ctx, `
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = ? AND tm.user_id = ?`, teamID, userID).Scan(
&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
if err != nil {
return nil, err
}
return &m, nil
}
// GetUserTeamIDs returns all team IDs a user belongs to.
func (s *TeamStore) GetUserTeamIDs(ctx context.Context, userID string) ([]string, error) {
rows, err := DB.QueryContext(ctx,
"SELECT team_id FROM team_members WHERE user_id = ?", userID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *TeamStore) IsTeamAdmin(ctx context.Context, teamID, userID string) (bool, error) {
var role string
err := DB.QueryRowContext(ctx,
"SELECT role FROM team_members WHERE team_id = ? AND user_id = ?",
teamID, userID).Scan(&role)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return role == models.TeamRoleAdmin, nil
}
func (s *TeamStore) IsMember(ctx context.Context, teamID, userID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = ? AND user_id = ?)",
teamID, userID).Scan(&exists)
return exists, err
}
// unused but keeping for reference
var _ = fmt.Sprintf

View File

@@ -0,0 +1,242 @@
package sqlite
import (
"context"
"fmt"
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type UsageStore struct{}
func NewUsageStore() *UsageStore { return &UsageStore{} }
// Log inserts a usage entry.
func (s *UsageStore) Log(ctx context.Context, entry *models.UsageEntry) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO usage_log (
id, channel_id, user_id, provider_config_id, provider_scope,
model_id, role,
prompt_tokens, completion_tokens,
cache_creation_tokens, cache_read_tokens,
cost_input, cost_output
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
store.NewID(), entry.ChannelID, entry.UserID, entry.ProviderConfigID, entry.ProviderScope,
entry.ModelID, entry.Role,
entry.PromptTokens, entry.CompletionTokens,
entry.CacheCreationTokens, entry.CacheReadTokens,
entry.CostInput, entry.CostOutput,
)
return err
}
// QueryByUser returns aggregated usage for a specific user.
func (s *UsageStore) QueryByUser(ctx context.Context, userID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
where := []string{"user_id = ?"}
args := []interface{}{userID}
return s.query(ctx, where, args, opts)
}
// QueryByUserPersonal returns aggregated usage for a user's own (BYOK) providers only.
func (s *UsageStore) QueryByUserPersonal(ctx context.Context, userID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
where := []string{
"user_id = ?",
"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = ?)",
}
args := []interface{}{userID, userID}
return s.query(ctx, where, args, opts)
}
// QueryByTeam returns aggregated usage for all members of a team (admin view).
func (s *UsageStore) QueryByTeam(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
where := []string{"user_id IN (SELECT user_id FROM team_members WHERE team_id = ?)"}
args := []interface{}{teamID}
return s.query(ctx, where, args, opts)
}
// QueryByTeamProviders returns usage against providers owned by this team.
// Used by team admins to see how their team's API keys are being consumed.
func (s *UsageStore) QueryByTeamProviders(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
where := []string{"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'team' AND owner_id = ?)"}
args := []interface{}{teamID}
return s.query(ctx, where, args, opts)
}
// QueryByModel returns aggregated usage grouped by model.
func (s *UsageStore) QueryByModel(ctx context.Context, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
return s.query(ctx, nil, nil, opts)
}
// GetTotals returns aggregate totals (admin view — excludes BYOK by default).
func (s *UsageStore) GetTotals(ctx context.Context, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
where, args := s.buildFilters(nil, nil, opts)
q := fmt.Sprintf(`
SELECT
COUNT(*),
COALESCE(SUM(prompt_tokens), 0),
COALESCE(SUM(completion_tokens), 0),
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
FROM usage_log
WHERE %s
`, strings.Join(where, " AND "))
var t models.UsageTotals
err := DB.QueryRowContext(ctx, q, args...).Scan(
&t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
)
return &t, err
}
// GetTeamProviderTotals returns aggregate totals for providers owned by a team.
func (s *UsageStore) GetTeamProviderTotals(ctx context.Context, teamID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
baseWhere := []string{"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'team' AND owner_id = ?)"}
baseArgs := []interface{}{teamID}
where, args := s.buildFilters(baseWhere, baseArgs, opts)
q := fmt.Sprintf(`
SELECT
COUNT(*),
COALESCE(SUM(prompt_tokens), 0),
COALESCE(SUM(completion_tokens), 0),
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
FROM usage_log
WHERE %s
`, strings.Join(where, " AND "))
var t models.UsageTotals
err := DB.QueryRowContext(ctx, q, args...).Scan(
&t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
)
return &t, err
}
// GetPersonalTotals returns aggregate totals for a user's own provider usage.
// Only includes usage against personal (BYOK) providers — global provider
// costs are the org's responsibility, not the user's.
func (s *UsageStore) GetPersonalTotals(ctx context.Context, userID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
baseWhere := []string{
"user_id = ?",
"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = ?)",
}
baseArgs := []interface{}{userID, userID}
opts.ExcludeBYOK = false
where, args := s.buildFilters(baseWhere, baseArgs, opts)
q := fmt.Sprintf(`
SELECT
COUNT(*),
COALESCE(SUM(prompt_tokens), 0),
COALESCE(SUM(completion_tokens), 0),
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
FROM usage_log
WHERE %s
`, strings.Join(where, " AND "))
var t models.UsageTotals
err := DB.QueryRowContext(ctx, q, args...).Scan(
&t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
)
return &t, err
}
// CountRecentByRole counts how many usage entries a user has for a given
// role within the specified duration. Used for rate limiting utility calls.
func (s *UsageStore) CountRecentByRole(ctx context.Context, userID, role string, since time.Time) (int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM usage_log
WHERE user_id = ? AND role = ? AND created_at >= ?
`, userID, role, since).Scan(&count)
return count, err
}
// ── Internal ───────────────────────────────
func (s *UsageStore) buildFilters(baseWhere []string, baseArgs []interface{}, opts store.UsageQueryOptions) ([]string, []interface{}) {
where := append([]string{}, baseWhere...)
args := append([]interface{}{}, baseArgs...)
if opts.ExcludeBYOK {
where = append(where, "provider_scope != ?")
args = append(args, "personal")
}
if opts.Since != nil {
where = append(where, "created_at >= ?")
args = append(args, *opts.Since)
}
if opts.Until != nil {
where = append(where, "created_at < ?")
args = append(args, *opts.Until)
}
if len(where) == 0 {
where = append(where, "1=1")
}
return where, args
}
func (s *UsageStore) query(ctx context.Context, baseWhere []string, baseArgs []interface{}, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
where, args := s.buildFilters(baseWhere, baseArgs, opts)
// Determine GROUP BY expression and label
groupExpr := "model_id"
labelExpr := "model_id"
switch opts.GroupBy {
case "day":
groupExpr = "DATE(created_at)"
labelExpr = "DATE(created_at)"
case "user":
groupExpr = "user_id"
labelExpr = "user_id"
case "provider":
groupExpr = "provider_config_id"
labelExpr = "provider_config_id"
case "model":
groupExpr = "model_id"
labelExpr = "model_id"
default:
groupExpr = "model_id"
labelExpr = "model_id"
}
limit := opts.Limit
if limit <= 0 {
limit = 100
}
q := fmt.Sprintf(`
SELECT
%s AS group_key,
%s AS label,
COUNT(*) AS requests,
COALESCE(SUM(prompt_tokens), 0) AS input_tokens,
COALESCE(SUM(completion_tokens), 0) AS output_tokens,
COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0) AS total_cost
FROM usage_log
WHERE %s
GROUP BY %s
ORDER BY total_cost DESC
LIMIT %d
`, groupExpr, labelExpr, strings.Join(where, " AND "), groupExpr, limit)
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []models.UsageAggregate
for rows.Next() {
var a models.UsageAggregate
if err := rows.Scan(&a.GroupKey, &a.Label, &a.Requests, &a.InputTokens, &a.OutputTokens, &a.TotalCost); err != nil {
return nil, err
}
results = append(results, a)
}
return results, rows.Err()
}

222
server/store/sqlite/user.go Normal file
View File

@@ -0,0 +1,222 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type UserStore struct{}
func NewUserStore() *UserStore { return &UserStore{} }
func (s *UserStore) Create(ctx context.Context, u *models.User) error {
u.ID = store.NewID()
now := time.Now().UTC()
u.CreatedAt = now
u.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO users (id, username, email, password_hash, display_name, role, is_active, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, ToJSON(u.Settings),
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error) {
return s.getBy(ctx, "id", id)
}
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE LOWER(username) = LOWER(?)`, username).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
}
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*models.User, error) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE LOWER(email) = LOWER(?)`, email).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
}
func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, error) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)`, login, login).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
}
func (s *UserStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("users")
for k, v := range fields {
if k == "settings" {
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 *UserStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM users WHERE id = ?", id)
return err
}
func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.User, int, error) {
b := NewSelect(
"id, username, email, display_name, avatar_url, role, is_active, settings, created_at, updated_at, last_login_at",
"users",
)
if opts.Sort == "" {
b.OrderBy("username", "ASC")
}
b.Paginate(opts)
// Count
var total int
DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").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.User
for rows.Next() {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := rows.Scan(&u.ID, &u.Username, &u.Email, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt))
if err != nil {
return nil, 0, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
result = append(result, u)
}
return result, total, rows.Err()
}
func (s *UserStore) UpdateLastLogin(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "UPDATE users SET last_login_at = datetime('now') WHERE id = ?", id)
return err
}
func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error {
_, err := DB.ExecContext(ctx, "UPDATE users SET is_active = ? WHERE id = ?", active, id)
return err
}
// ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
VALUES (?, ?, ?, ?)`, store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt))
return err
}
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
var userID string
err := DB.QueryRowContext(ctx, `
SELECT user_id FROM refresh_tokens
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`,
tokenHash).Scan(&userID)
return userID, err
}
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash)
return err
}
func (s *UserStore) RevokeAllRefreshTokens(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL", userID)
return err
}
func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
_, err := DB.ExecContext(ctx,
"DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')")
return err
}
// ── Internal ────────────────────────────────
func (s *UserStore) getBy(ctx context.Context, col, val string) (*models.User, error) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, fmt.Sprintf(`
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE %s = ?`, col), val).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
}

View File

@@ -0,0 +1,155 @@
package sqlite
import (
"context"
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type UserModelSettingsStore struct{}
func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSettingsStore{} }
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens,
sort_order, created_at, updated_at
FROM user_model_settings WHERE user_id = ? ORDER BY sort_order, model_id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.UserModelSetting
for rows.Next() {
var s models.UserModelSetting
var prefTemp, prefMaxTokens interface{}
err := rows.Scan(
&s.ID, &s.UserID, &s.ModelID, &s.Hidden,
&prefTemp, &prefMaxTokens,
&s.SortOrder, st(&s.CreatedAt), st(&s.UpdatedAt),
)
if err != nil {
return nil, err
}
if f, ok := prefTemp.(float64); ok {
s.PreferredTemperature = &f
}
if n, ok := prefMaxTokens.(int64); ok {
v := int(n)
s.PreferredMaxTokens = &v
}
result = append(result, s)
}
return result, rows.Err()
}
// GetHiddenModelIDs returns a map of model_id → true for all hidden models.
func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
rows, err := DB.QueryContext(ctx,
"SELECT model_id FROM user_model_settings WHERE user_id = ? AND hidden = 1",
userID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]bool)
for rows.Next() {
var modelID string
if err := rows.Scan(&modelID); err != nil {
return nil, err
}
result[modelID] = true
}
return result, rows.Err()
}
// Set upserts a single user model setting.
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error {
b := NewUpdate("user_model_settings")
if patch.Hidden != nil {
b.Set("hidden", *patch.Hidden)
}
if patch.PreferredTemperature != nil {
b.Set("preferred_temperature", models.NullFloat(patch.PreferredTemperature))
}
if patch.PreferredMaxTokens != nil {
b.Set("preferred_max_tokens", models.NullInt(patch.PreferredMaxTokens))
}
if patch.SortOrder != nil {
b.Set("sort_order", *patch.SortOrder)
}
if !b.HasSets() {
return nil
}
// Use upsert: insert if not exists, update if exists
// Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT
_, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, model_id)
DO UPDATE SET
hidden = COALESCE(excluded.hidden, user_model_settings.hidden),
preferred_temperature = COALESCE(excluded.preferred_temperature, user_model_settings.preferred_temperature),
preferred_max_tokens = COALESCE(excluded.preferred_max_tokens, user_model_settings.preferred_max_tokens),
sort_order = COALESCE(excluded.sort_order, user_model_settings.sort_order)`,
store.NewID(), userID, modelID,
patchBoolOrNil(patch.Hidden),
patchFloat64OrNil(patch.PreferredTemperature),
patchIntOrNil(patch.PreferredMaxTokens),
patchIntOrNil(patch.SortOrder),
)
return err
}
// BulkSetHidden sets the hidden state for multiple models at once.
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error {
if len(modelIDs) == 0 {
return nil
}
// Upsert each model individually
for _, modelID := range modelIDs {
_, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (id, user_id, model_id, hidden)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = excluded.hidden`,
store.NewID(), userID, modelID, hidden)
if err != nil {
return fmt.Errorf("set hidden for %s: %w", modelID, err)
}
}
return nil
}
// ── Helpers ─────────────────────────────────
func patchBoolOrNil(b *bool) interface{} {
if b == nil {
return nil
}
return *b
}
func patchFloat64OrNil(f *float64) interface{} {
if f == nil {
return nil
}
return *f
}
func patchIntOrNil(i *int) interface{} {
if i == nil {
return nil
}
return *i
}
// unused but keeping for reference - will be used in ListOptions-based queries
var _ = strings.Join