Changeset 0.17.1 (#76)
This commit is contained in:
10
server/store/id.go
Normal file
10
server/store/id.go
Normal 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()
|
||||
}
|
||||
190
server/store/sqlite/attachment.go
Normal file
190
server/store/sqlite/attachment.go
Normal 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()
|
||||
}
|
||||
84
server/store/sqlite/audit.go
Normal file
84
server/store/sqlite/audit.go
Normal 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()
|
||||
}
|
||||
241
server/store/sqlite/catalog.go
Normal file
241
server/store/sqlite/catalog.go
Normal 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()
|
||||
}
|
||||
227
server/store/sqlite/channel.go
Normal file
227
server/store/sqlite/channel.go
Normal 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
|
||||
}
|
||||
201
server/store/sqlite/extension.go
Normal file
201
server/store/sqlite/extension.go
Normal 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()
|
||||
}
|
||||
58
server/store/sqlite/global_config.go
Normal file
58
server/store/sqlite/global_config.go
Normal 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()
|
||||
}
|
||||
213
server/store/sqlite/groups.go
Normal file
213
server/store/sqlite/groups.go
Normal 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()
|
||||
}
|
||||
328
server/store/sqlite/helpers.go
Normal file
328
server/store/sqlite/helpers.go
Normal 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.
|
||||
641
server/store/sqlite/knowledge_bases.go
Normal file
641
server/store/sqlite/knowledge_bases.go
Normal 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), ",")
|
||||
}
|
||||
190
server/store/sqlite/message.go
Normal file
190
server/store/sqlite/message.go
Normal 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
199
server/store/sqlite/note.go
Normal 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()
|
||||
}
|
||||
410
server/store/sqlite/persona.go
Normal file
410
server/store/sqlite/persona.go
Normal 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()
|
||||
}
|
||||
62
server/store/sqlite/policy.go
Normal file
62
server/store/sqlite/policy.go
Normal 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()
|
||||
}
|
||||
132
server/store/sqlite/pricing.go
Normal file
132
server/store/sqlite/pricing.go
Normal 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
|
||||
}
|
||||
205
server/store/sqlite/provider.go
Normal file
205
server/store/sqlite/provider.go
Normal 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()
|
||||
}
|
||||
106
server/store/sqlite/resource_grants.go
Normal file
106
server/store/sqlite/resource_grants.go
Normal 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
|
||||
}
|
||||
34
server/store/sqlite/stores.go
Normal file
34
server/store/sqlite/stores.go
Normal 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
205
server/store/sqlite/team.go
Normal 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
|
||||
242
server/store/sqlite/usage.go
Normal file
242
server/store/sqlite/usage.go
Normal 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
222
server/store/sqlite/user.go
Normal 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
|
||||
}
|
||||
155
server/store/sqlite/user_settings.go
Normal file
155
server/store/sqlite/user_settings.go
Normal 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
|
||||
Reference in New Issue
Block a user