From c3e9dcf731b3131cd7e60aaecc0d7434f99f5017 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Wed, 25 Mar 2026 19:58:14 -0400 Subject: [PATCH] step 3: gut store interfaces and models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - interfaces.go: 1449 → 437 lines (13 kernel interfaces from 33) - models.go: 1137 → 397 lines (17 kernel types from 64) - Deleted 28 store impl files per dialect (PG + SQLite) - Deleted 3 model files (git, memory, workspace) - Deleted 3 store iface files (project, memory, tree_types) - Rewrote stores.go constructors for both PG and SQLite - Stores struct: 20 fields (from 40+, includes separate iface stores) 66 files changed, ~19K lines removed. Build will break — expected, fixed in steps 4-5. --- server/models/models.go | 740 ----------- server/models/models_git.go | 70 - server/models/models_memory.go | 48 - server/models/models_workspace.go | 116 -- server/store/interfaces.go | 1020 +-------------- server/store/postgres/cap_override.go | 85 -- server/store/postgres/catalog.go | 338 ----- server/store/postgres/catalog_mention.go | 57 - server/store/postgres/channel.go | 1034 --------------- server/store/postgres/channel_helpers.go | 146 --- server/store/postgres/export.go | 1433 -------------------- server/store/postgres/file.go | 260 ---- server/store/postgres/folder.go | 88 -- server/store/postgres/git_credentials.go | 80 -- server/store/postgres/knowledge_bases.go | 546 -------- server/store/postgres/memory.go | 336 ----- server/store/postgres/memory_hybrid.go | 131 -- server/store/postgres/message.go | 306 ----- server/store/postgres/message_tree.go | 383 ------ server/store/postgres/note.go | 318 ----- server/store/postgres/note_link.go | 179 --- server/store/postgres/persona.go | 418 ------ server/store/postgres/persona_group.go | 138 -- server/store/postgres/persona_mention.go | 80 -- server/store/postgres/policy.go | 68 - server/store/postgres/pricing.go | 131 -- server/store/postgres/project.go | 464 ------- server/store/postgres/provider.go | 258 ---- server/store/postgres/routing_policy.go | 110 -- server/store/postgres/stores.go | 46 +- server/store/postgres/usage.go | 246 ---- server/store/postgres/user_mention.go | 62 - server/store/postgres/user_settings.go | 149 --- server/store/postgres/workspace.go | 398 ------ server/store/project_interface.go | 70 - server/store/sqlite/cap_override.go | 90 -- server/store/sqlite/catalog.go | 333 ----- server/store/sqlite/catalog_mention.go | 57 - server/store/sqlite/channel.go | 972 -------------- server/store/sqlite/channel_helpers.go | 143 -- server/store/sqlite/export.go | 1515 ---------------------- server/store/sqlite/file.go | 265 ---- server/store/sqlite/folder.go | 95 -- server/store/sqlite/git_credentials.go | 87 -- server/store/sqlite/knowledge_bases.go | 627 --------- server/store/sqlite/memory.go | 320 ----- server/store/sqlite/memory_hybrid.go | 146 --- server/store/sqlite/message.go | 310 ----- server/store/sqlite/message_tree.go | 382 ------ server/store/sqlite/note.go | 305 ----- server/store/sqlite/note_link.go | 182 --- server/store/sqlite/persona.go | 423 ------ server/store/sqlite/persona_group.go | 144 -- server/store/sqlite/persona_mention.go | 80 -- server/store/sqlite/policy.go | 62 - server/store/sqlite/pricing.go | 132 -- server/store/sqlite/project.go | 502 ------- server/store/sqlite/provider.go | 266 ---- server/store/sqlite/routing_policy.go | 116 -- server/store/sqlite/stores.go | 46 +- server/store/sqlite/usage.go | 242 ---- server/store/sqlite/user_mention.go | 62 - server/store/sqlite/user_settings.go | 150 --- server/store/sqlite/workspace.go | 452 ------- server/store/store_memory.go | 78 -- server/store/tree_types.go | 54 - 66 files changed, 28 insertions(+), 18962 deletions(-) delete mode 100644 server/models/models_git.go delete mode 100644 server/models/models_memory.go delete mode 100644 server/models/models_workspace.go delete mode 100644 server/store/postgres/cap_override.go delete mode 100644 server/store/postgres/catalog.go delete mode 100644 server/store/postgres/catalog_mention.go delete mode 100644 server/store/postgres/channel.go delete mode 100644 server/store/postgres/channel_helpers.go delete mode 100644 server/store/postgres/export.go delete mode 100644 server/store/postgres/file.go delete mode 100644 server/store/postgres/folder.go delete mode 100644 server/store/postgres/git_credentials.go delete mode 100644 server/store/postgres/knowledge_bases.go delete mode 100644 server/store/postgres/memory.go delete mode 100644 server/store/postgres/memory_hybrid.go delete mode 100644 server/store/postgres/message.go delete mode 100644 server/store/postgres/message_tree.go delete mode 100644 server/store/postgres/note.go delete mode 100644 server/store/postgres/note_link.go delete mode 100644 server/store/postgres/persona.go delete mode 100644 server/store/postgres/persona_group.go delete mode 100644 server/store/postgres/persona_mention.go delete mode 100644 server/store/postgres/policy.go delete mode 100644 server/store/postgres/pricing.go delete mode 100644 server/store/postgres/project.go delete mode 100644 server/store/postgres/provider.go delete mode 100644 server/store/postgres/routing_policy.go delete mode 100644 server/store/postgres/usage.go delete mode 100644 server/store/postgres/user_mention.go delete mode 100644 server/store/postgres/user_settings.go delete mode 100644 server/store/postgres/workspace.go delete mode 100644 server/store/project_interface.go delete mode 100644 server/store/sqlite/cap_override.go delete mode 100644 server/store/sqlite/catalog.go delete mode 100644 server/store/sqlite/catalog_mention.go delete mode 100644 server/store/sqlite/channel.go delete mode 100644 server/store/sqlite/channel_helpers.go delete mode 100644 server/store/sqlite/export.go delete mode 100644 server/store/sqlite/file.go delete mode 100644 server/store/sqlite/folder.go delete mode 100644 server/store/sqlite/git_credentials.go delete mode 100644 server/store/sqlite/knowledge_bases.go delete mode 100644 server/store/sqlite/memory.go delete mode 100644 server/store/sqlite/memory_hybrid.go delete mode 100644 server/store/sqlite/message.go delete mode 100644 server/store/sqlite/message_tree.go delete mode 100644 server/store/sqlite/note.go delete mode 100644 server/store/sqlite/note_link.go delete mode 100644 server/store/sqlite/persona.go delete mode 100644 server/store/sqlite/persona_group.go delete mode 100644 server/store/sqlite/persona_mention.go delete mode 100644 server/store/sqlite/policy.go delete mode 100644 server/store/sqlite/pricing.go delete mode 100644 server/store/sqlite/project.go delete mode 100644 server/store/sqlite/provider.go delete mode 100644 server/store/sqlite/routing_policy.go delete mode 100644 server/store/sqlite/usage.go delete mode 100644 server/store/sqlite/user_mention.go delete mode 100644 server/store/sqlite/user_settings.go delete mode 100644 server/store/sqlite/workspace.go delete mode 100644 server/store/store_memory.go delete mode 100644 server/store/tree_types.go diff --git a/server/models/models.go b/server/models/models.go index a7598bb..bfa681d 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -23,52 +23,6 @@ const ( ScopePersonal = "personal" ) -// ── Visibility Constants ──────────────────── - -const ( - VisibilityVisible = "visible" - VisibilityHidden = "hidden" -) - -// ── Role Constants ────────────────────────── - -const ( - UserRoleUser = "user" - UserRoleAdmin = "admin" - - TeamRoleAdmin = "admin" - TeamRoleMember = "member" -) - -// ── Grant Type Constants ──────────────────── - -const ( - GrantTypeTool = "tool" - GrantTypeKnowledgeBase = "knowledge_base" - GrantTypeAPIEndpoint = "api_endpoint" -) - -// ── Channel Type Constants ────────────────── - -const ( - ChannelTypeDirect = "direct" - ChannelTypeGroup = "group" - ChannelTypeChannel = "channel" -) - -// ── Message Role Constants ────────────────── - -const ( - MessageRoleUser = "user" - MessageRoleAssistant = "assistant" - MessageRoleSystem = "system" - MessageRoleTool = "tool" -) - -// ========================================= -// USERS -// ========================================= - type User struct { BaseModel Username string `json:"username" db:"username"` @@ -113,146 +67,21 @@ type TeamMember struct { UserRole string `json:"user_role,omitempty"` } -// ========================================= // PROVIDER CONFIGS (replaces APIConfig) -// ========================================= - -type ProviderConfig struct { - BaseModel - Scope string `json:"scope" db:"scope"` - OwnerID *string `json:"owner_id,omitempty" db:"owner_id"` - Name string `json:"name" db:"name"` - Provider string `json:"provider" db:"provider"` - Endpoint string `json:"endpoint" db:"endpoint"` - APIKeyEnc []byte `json:"-" db:"api_key_enc"` - KeyNonce []byte `json:"-" db:"key_nonce"` - KeyScope string `json:"-" db:"key_scope"` - ModelDefault string `json:"model_default,omitempty" db:"model_default"` - Config JSONMap `json:"config,omitempty" db:"config"` - Headers JSONMap `json:"headers,omitempty" db:"headers"` - Settings JSONMap `json:"settings,omitempty" db:"settings"` - IsActive bool `json:"is_active" db:"is_active"` - IsPrivate bool `json:"is_private" db:"is_private"` - ProxyMode string `json:"proxy_mode" db:"proxy_mode"` - ProxyURL *string `json:"proxy_url,omitempty" db:"proxy_url"` -} // HasKey returns true if an encrypted API key is stored. func (p *ProviderConfig) HasKey() bool { return len(p.APIKeyEnc) > 0 } -type ProviderConfigPatch struct { - Name *string `json:"name,omitempty"` - Endpoint *string `json:"endpoint,omitempty"` - APIKeyEnc []byte `json:"-"` - KeyNonce []byte `json:"-"` - ModelDefault *string `json:"model_default,omitempty"` - Config JSONMap `json:"config,omitempty"` - Headers JSONMap `json:"headers,omitempty"` - Settings JSONMap `json:"settings,omitempty"` - IsActive *bool `json:"is_active,omitempty"` - IsPrivate *bool `json:"is_private,omitempty"` - ProxyMode *string `json:"proxy_mode,omitempty"` - ProxyURL *string `json:"proxy_url,omitempty"` -} - -// ========================================= // MODEL CATALOG (replaces model_configs) -// ========================================= - -type CatalogEntry struct { - BaseModel - ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"` - ModelID string `json:"model_id" db:"model_id"` - DisplayName string `json:"display_name,omitempty" db:"display_name"` - ModelType string `json:"model_type" db:"model_type"` // "chat", "embedding", "image", etc. - Capabilities ModelCapabilities `json:"capabilities" db:"capabilities"` - Pricing *ModelPricing `json:"pricing,omitempty" db:"pricing"` - Visibility string `json:"visibility" db:"visibility"` - LastSyncedAt *time.Time `json:"last_synced_at,omitempty" db:"last_synced_at"` -} - -type ModelCapabilities struct { - Streaming bool `json:"streaming"` - ToolCalling bool `json:"tool_calling"` - Vision bool `json:"vision"` - Thinking bool `json:"thinking"` - Reasoning bool `json:"reasoning"` - CodeOptimized bool `json:"code_optimized"` - WebSearch bool `json:"web_search"` - MaxContext int `json:"max_context"` - MaxOutputTokens int `json:"max_output_tokens"` -} func (c ModelCapabilities) HasProviderData() bool { return c.ToolCalling || c.Vision || c.Thinking || c.Reasoning || c.CodeOptimized || c.WebSearch || c.MaxContext > 0 || c.MaxOutputTokens > 0 } -type ModelPricing struct { - InputPerM float64 `json:"input_per_m,omitempty"` - OutputPerM float64 `json:"output_per_m,omitempty"` - Currency string `json:"currency,omitempty"` -} - -// ========================================= // PERSONAS (replaces ModelPreset) -// ========================================= - -type Persona struct { - BaseModel - Name string `json:"name" db:"name"` - Handle string `json:"handle,omitempty" db:"handle"` - Description string `json:"description,omitempty" db:"description"` - Icon string `json:"icon,omitempty" db:"icon"` - Avatar string `json:"avatar,omitempty" db:"avatar"` - - BaseModelID string `json:"base_model_id" db:"base_model_id"` - ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - - SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` - Temperature *float64 `json:"temperature,omitempty" db:"temperature"` - MaxTokens *int `json:"max_tokens,omitempty" db:"max_tokens"` - ThinkingBudget *int `json:"thinking_budget,omitempty" db:"thinking_budget"` - TopP *float64 `json:"top_p,omitempty" db:"top_p"` - - Scope string `json:"scope" db:"scope"` - OwnerID *string `json:"owner_id,omitempty" db:"owner_id"` - CreatedBy string `json:"created_by" db:"created_by"` - - IsActive bool `json:"is_active" db:"is_active"` - IsShared bool `json:"is_shared" db:"is_shared"` - - // Loaded from persona_grants, not stored in personas table - Grants []Grant `json:"grants,omitempty" db:"-"` - - // Loaded from persona_knowledge_bases, not stored in personas table - KBIDs []string `json:"kb_ids,omitempty" db:"-"` - - // Memory configuration (v0.18.0) - MemoryEnabled bool `json:"memory_enabled" db:"memory_enabled"` - MemoryExtractionPrompt *string `json:"memory_extraction_prompt,omitempty" db:"memory_extraction_prompt"` -} - -type PersonaPatch struct { - Name *string `json:"name,omitempty"` - Handle *string `json:"handle,omitempty"` - Description *string `json:"description,omitempty"` - Icon *string `json:"icon,omitempty"` - Avatar *string `json:"avatar,omitempty"` - BaseModelID *string `json:"base_model_id,omitempty"` - ProviderConfigID *string `json:"provider_config_id,omitempty"` - SystemPrompt *string `json:"system_prompt,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - ThinkingBudget *int `json:"thinking_budget,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - IsActive *bool `json:"is_active,omitempty"` - IsShared *bool `json:"is_shared,omitempty"` - MemoryEnabled *bool `json:"memory_enabled,omitempty"` - MemoryExtractionPrompt *string `json:"memory_extraction_prompt,omitempty"` -} // ========================================= // GRANTS @@ -267,9 +96,7 @@ type Grant struct { CreatedAt time.Time `json:"created_at" db:"created_at"` } -// ========================================= // PLATFORM POLICIES -// ========================================= var PolicyDefaults = map[string]string{ "allow_user_byok": "false", @@ -281,161 +108,25 @@ var PolicyDefaults = map[string]string{ "default_model": "", } -// ========================================= // USER MODEL SETTINGS -// ========================================= - -type UserModelSetting struct { - BaseModel - UserID string `json:"user_id" db:"user_id"` - ModelID string `json:"model_id" db:"model_id"` - ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - Hidden bool `json:"hidden" db:"hidden"` - PreferredTemperature *float64 `json:"preferred_temperature,omitempty" db:"preferred_temperature"` - PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty" db:"preferred_max_tokens"` - SortOrder int `json:"sort_order" db:"sort_order"` -} - -type UserModelSettingPatch struct { - ProviderConfigID *string `json:"provider_config_id,omitempty"` - Hidden *bool `json:"hidden,omitempty"` - PreferredTemperature *float64 `json:"preferred_temperature,omitempty"` - PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"` - SortOrder *int `json:"sort_order,omitempty"` -} // HiddenEntry identifies a model+provider pair for bulk visibility operations. -type HiddenEntry struct { - ModelID string `json:"model_id"` - ProviderConfigID string `json:"provider_config_id"` -} - // CompositeModelKey builds the composite key used for per-provider model preferences. func CompositeModelKey(providerConfigID, modelID string) string { return providerConfigID + ":" + modelID } -// ========================================= // CHANNELS -// ========================================= -type Channel struct { - BaseModel - UserID string `json:"user_id" db:"user_id"` - Title string `json:"title" db:"title"` - Description string `json:"description,omitempty" db:"description"` - Type string `json:"type" db:"type"` - Model string `json:"model,omitempty" db:"model"` - SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` - ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - IsArchived bool `json:"is_archived" db:"is_archived"` - IsPinned bool `json:"is_pinned" db:"is_pinned"` - PurgeAfter *time.Time `json:"purge_after,omitempty" db:"purge_after"` - FolderID *string `json:"folder_id,omitempty" db:"folder_id"` - TeamID *string `json:"team_id,omitempty" db:"team_id"` - ProjectID *string `json:"project_id,omitempty" db:"project_id"` - WorkspaceID *string `json:"workspace_id,omitempty" db:"workspace_id"` - Settings JSONMap `json:"settings,omitempty" db:"settings"` - AllowAnonymous bool `json:"allow_anonymous" db:"allow_anonymous"` // v0.24.3: session participants can join -} - -// ========================================= // SESSION PARTICIPANTS (v0.24.3) -// ========================================= // SessionParticipant is an ephemeral identity for anonymous workflow // channel visitors. Scoped to a single channel, no users row required. -type SessionParticipant struct { - ID string `json:"id" db:"id"` - SessionToken string `json:"session_token" db:"session_token"` - ChannelID string `json:"channel_id" db:"channel_id"` - DisplayName string `json:"display_name" db:"display_name"` - Fingerprint string `json:"fingerprint,omitempty" db:"fingerprint"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// ========================================= // MESSAGES -// ========================================= -type Message struct { - BaseModel - ChannelID string `json:"channel_id" db:"channel_id"` - Role string `json:"role" db:"role"` - Content string `json:"content" db:"content"` - Model string `json:"model,omitempty" db:"model"` - TokensUsed int `json:"tokens_used,omitempty" db:"tokens_used"` - ToolCalls JSONMap `json:"tool_calls,omitempty" db:"tool_calls"` - Metadata JSONMap `json:"metadata,omitempty" db:"metadata"` - ParentID *string `json:"parent_id,omitempty" db:"parent_id"` - SiblingIndex int `json:"sibling_index" db:"sibling_index"` - ParticipantType string `json:"participant_type,omitempty" db:"participant_type"` - ParticipantID string `json:"participant_id,omitempty" db:"participant_id"` - ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"` -} - -// ========================================= // CHANNEL PARTICIPANTS, MODELS, CURSORS -// ========================================= -type ChannelParticipant struct { - ID string `json:"id" db:"id"` - ChannelID string `json:"channel_id" db:"channel_id"` - ParticipantType string `json:"participant_type" db:"participant_type"` - ParticipantID string `json:"participant_id" db:"participant_id"` - Role string `json:"role" db:"role"` - DisplayName *string `json:"display_name,omitempty" db:"display_name"` - AvatarURL *string `json:"avatar_url,omitempty" db:"avatar_url"` - JoinedAt time.Time `json:"joined_at" db:"joined_at"` - LastReadAt time.Time `json:"last_read_at" db:"last_read_at"` -} - -type ChannelModel struct { - ID string `json:"id" db:"id"` - ChannelID string `json:"channel_id" db:"channel_id"` - ModelID string `json:"model_id" db:"model_id"` - ProviderConfigID string `json:"provider_config_id,omitempty" db:"provider_config_id"` - PersonaID *string `json:"persona_id,omitempty" db:"persona_id"` - Handle string `json:"handle,omitempty" db:"handle"` - DisplayName string `json:"display_name,omitempty" db:"display_name"` - SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` - IsDefault bool `json:"is_default" db:"is_default"` -} - -type ChannelCursor struct { - ID string `json:"id" db:"id"` - ChannelID string `json:"channel_id" db:"channel_id"` - UserID string `json:"user_id" db:"user_id"` - ActiveLeafID *string `json:"active_leaf_id,omitempty" db:"active_leaf_id"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// ========================================= // PERSONA GROUPS (v0.23.0) -// ========================================= - -type PersonaGroup struct { - BaseModel - Name string `json:"name" db:"name"` - Description string `json:"description,omitempty" db:"description"` - OwnerID string `json:"owner_id" db:"owner_id"` - Scope string `json:"scope" db:"scope"` - TeamID *string `json:"team_id,omitempty" db:"team_id"` - Members []PersonaGroupMember `json:"members" db:"-"` -} - -type PersonaGroupMember struct { - ID string `json:"id" db:"id"` - GroupID string `json:"group_id" db:"group_id"` - PersonaID string `json:"persona_id" db:"persona_id"` - IsLeader bool `json:"is_leader" db:"is_leader"` - SortOrder int `json:"sort_order" db:"sort_order"` - // Joined from personas table for API responses - PersonaName string `json:"persona_name,omitempty" db:"-"` - PersonaHandle string `json:"persona_handle,omitempty" db:"-"` - PersonaAvatar string `json:"persona_avatar,omitempty" db:"-"` -} // HandleFromName generates a URL-safe @mention handle from a display name. // "Veronica Sharpe" → "veronica-sharpe" @@ -461,190 +152,26 @@ func HandleFromName(name string) string { return h } -// ========================================= // ORGANIZATION -// ========================================= - -type Folder struct { - BaseModel - UserID string `json:"user_id" db:"user_id"` - Name string `json:"name" db:"name"` - ParentID *string `json:"parent_id,omitempty" db:"parent_id"` - SortOrder int `json:"sort_order" db:"sort_order"` -} - -type Project struct { - BaseModel - Name string `json:"name" db:"name"` - Description string `json:"description,omitempty" db:"description"` - Color *string `json:"color,omitempty" db:"color"` - Icon *string `json:"icon,omitempty" db:"icon"` - Scope string `json:"scope" db:"scope"` // personal, team, global - OwnerID string `json:"owner_id" db:"owner_id"` - TeamID *string `json:"team_id,omitempty" db:"team_id"` - IsArchived bool `json:"is_archived" db:"is_archived"` - WorkspaceID *string `json:"workspace_id,omitempty" db:"workspace_id"` - Settings JSONMap `json:"settings,omitempty" db:"settings"` - - // Computed fields (not DB columns) - ChannelCount int `json:"channel_count,omitempty"` - KBCount int `json:"kb_count,omitempty"` - NoteCount int `json:"note_count,omitempty"` -} // ProjectPatch holds optional fields for updating a project. -type ProjectPatch struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Color *string `json:"color,omitempty"` - Icon *string `json:"icon,omitempty"` - IsArchived *bool `json:"is_archived,omitempty"` - WorkspaceID *string `json:"workspace_id,omitempty"` - Settings JSONMap `json:"settings,omitempty"` -} - // ProjectChannel represents a channel's membership in a project. -type ProjectChannel struct { - ProjectID string `json:"project_id" db:"project_id"` - ChannelID string `json:"channel_id" db:"channel_id"` - Position int `json:"position" db:"position"` - Folder string `json:"folder,omitempty" db:"folder"` - AddedAt string `json:"added_at" db:"added_at"` -} - // ProjectKB represents a KB's association with a project. -type ProjectKB struct { - ProjectID string `json:"project_id" db:"project_id"` - KBID string `json:"kb_id" db:"kb_id"` - AutoSearch bool `json:"auto_search" db:"auto_search"` - AddedAt string `json:"added_at" db:"added_at"` - Name string `json:"name,omitempty" db:"name"` // enriched via JOIN -} - // ProjectNote represents a note's association with a project. -type ProjectNote struct { - ProjectID string `json:"project_id" db:"project_id"` - NoteID string `json:"note_id" db:"note_id"` - AddedAt string `json:"added_at" db:"added_at"` - Title string `json:"title,omitempty" db:"title"` // enriched via JOIN -} - -// ========================================= // NOTES -// ========================================= - -type Note struct { - BaseModel - UserID string `json:"user_id" db:"user_id"` - Title string `json:"title" db:"title"` - Content string `json:"content" db:"content"` - FolderPath string `json:"folder_path" db:"folder_path"` - Tags []string `json:"tags,omitempty" db:"tags"` - Metadata JSONMap `json:"metadata,omitempty" db:"metadata"` - SourceChannelID *string `json:"source_channel_id,omitempty" db:"source_channel_id"` - SourceMessageID *string `json:"source_message_id,omitempty" db:"source_message_id"` - TeamID *string `json:"team_id,omitempty" db:"team_id"` -} // NoteLink represents a directed link extracted from [[wikilink]] syntax. -type NoteLink struct { - TargetNoteID *string `json:"target_note_id,omitempty"` - TargetTitle string `json:"target_title"` - DisplayText string `json:"display_text,omitempty"` - IsTransclusion bool `json:"is_transclusion"` -} - // ExportNoteLink is a note_link with source_note_id included (for export). -type ExportNoteLink struct { - SourceNoteID string `json:"source_note_id" db:"source_note_id"` - TargetNoteID *string `json:"target_note_id,omitempty" db:"target_note_id"` - TargetTitle string `json:"target_title" db:"target_title"` - DisplayText string `json:"display_text,omitempty" db:"display_text"` - IsTransclusion bool `json:"is_transclusion" db:"is_transclusion"` -} - // NoteLinkResult represents a backlink — a note that links to a given note. -type NoteLinkResult struct { - SourceNoteID string `json:"id"` - Title string `json:"title"` - FolderPath string `json:"folder_path"` - UpdatedAt time.Time `json:"updated_at"` - DisplayText string `json:"display_text,omitempty"` -} - // NoteGraphNode is a lightweight note representation for graph display. -type NoteGraphNode struct { - ID string `json:"id"` - Title string `json:"title"` - FolderPath string `json:"folder_path"` - Tags []string `json:"tags"` - UpdatedAt string `json:"updated_at"` - LinkCount int `json:"link_count"` -} - // NoteGraphEdge is a resolved link between two notes. -type NoteGraphEdge struct { - Source string `json:"source"` - Target string `json:"target"` - Title string `json:"title"` - IsTransclusion bool `json:"is_transclusion"` -} - // NoteGraphDangling is an unresolved [[link]] reference. -type NoteGraphDangling struct { - Source string `json:"source"` - Title string `json:"title"` -} - // NoteGraph is the full graph topology for a user's notes. -type NoteGraph struct { - Nodes []NoteGraphNode `json:"nodes"` - Edges []NoteGraphEdge `json:"edges"` - Unresolved []NoteGraphDangling `json:"unresolved"` -} - -// ========================================= // ATTACHMENTS -// ========================================= -// ========================================= // FILES -// ========================================= // FileOrigin constants -const ( - FileOriginUserUpload = "user_upload" - FileOriginToolOutput = "tool_output" - FileOriginSystem = "system" -) - -// FileDisplayHint constants -const ( - FileHintInline = "inline" - FileHintDownload = "download" - FileHintThumbnail = "thumbnail" -) - -type File struct { - ID string `json:"id" db:"id"` - ChannelID string `json:"channel_id" db:"channel_id"` - MessageID *string `json:"message_id,omitempty" db:"message_id"` - UserID string `json:"user_id" db:"user_id"` - ProjectID *string `json:"project_id,omitempty" db:"project_id"` - Origin string `json:"origin" db:"origin"` // user_upload, tool_output, system - Filename string `json:"filename" db:"filename"` - ContentType string `json:"content_type" db:"content_type"` - SizeBytes int64 `json:"size_bytes" db:"size_bytes"` - StorageKey string `json:"-" db:"storage_key"` // never expose filesystem path - DisplayHint string `json:"display_hint" db:"display_hint"` // inline, download, thumbnail - ExtractedText *string `json:"extracted_text,omitempty" db:"extracted_text"` - Metadata JSONMap `json:"metadata,omitempty" db:"metadata"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - - - // ========================================= // AUDIT LOG // ========================================= @@ -662,111 +189,14 @@ type AuditEntry struct { CreatedAt time.Time `json:"created_at" db:"created_at"` } -// ========================================= // USAGE TRACKING -// ========================================= -type UsageEntry struct { - ID string `json:"id" db:"id"` - ChannelID *string `json:"channel_id,omitempty" db:"channel_id"` - UserID string `json:"user_id" db:"user_id"` - ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - ProviderScope string `json:"provider_scope" db:"provider_scope"` - ModelID string `json:"model_id" db:"model_id"` - Role *string `json:"role,omitempty" db:"role"` // null=chat, "utility", "embedding" - PromptTokens int `json:"prompt_tokens" db:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens" db:"completion_tokens"` - CacheCreationTokens int `json:"cache_creation_tokens" db:"cache_creation_tokens"` - CacheReadTokens int `json:"cache_read_tokens" db:"cache_read_tokens"` - CostInput *float64 `json:"cost_input,omitempty" db:"cost_input"` - CostOutput *float64 `json:"cost_output,omitempty" db:"cost_output"` - RoutingDecision JSONMap `json:"routing_decision,omitempty" db:"routing_decision"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -type UsageAggregate struct { - GroupKey string `json:"group_key"` - Label string `json:"label"` - Requests int `json:"requests"` - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - TotalCost float64 `json:"total_cost"` -} - -type UsageTotals struct { - Requests int `json:"requests"` - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - TotalCost float64 `json:"total_cost"` - Period string `json:"period"` -} - -// ========================================= // MODEL PRICING -// ========================================= -type PricingEntry struct { - ID string `json:"id" db:"id"` - ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"` - ModelID string `json:"model_id" db:"model_id"` - InputPerM *float64 `json:"input_per_m" db:"input_per_m"` - OutputPerM *float64 `json:"output_per_m" db:"output_per_m"` - CacheCreatePerM *float64 `json:"cache_create_per_m" db:"cache_create_per_m"` - CacheReadPerM *float64 `json:"cache_read_per_m" db:"cache_read_per_m"` - Currency string `json:"currency" db:"currency"` - Source string `json:"source" db:"source"` // "catalog" | "manual" - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - UpdatedBy *string `json:"updated_by,omitempty" db:"updated_by"` -} - -// ========================================= // VIEW MODELS (computed, not stored) -// ========================================= // UserModel is the view model returned by the capability resolver. // Combines catalog entries + Personas for the frontend. -type UserModel struct { - ID string `json:"id"` - DisplayName string `json:"display_name"` - ModelID string `json:"model_id"` - ModelType string `json:"model_type"` // "chat", "embedding", "image", etc. - Source string `json:"source"` // "catalog", "persona", "live" - - ProviderConfigID string `json:"provider_config_id"` - ConfigID string `json:"config_id"` // Alias of ProviderConfigID for frontend compat - ProviderName string `json:"provider_name"` - ProviderType string `json:"provider_type"` - - Capabilities ModelCapabilities `json:"capabilities"` - - // Persona fields — always emitted so frontend can branch on is_persona. - IsPersona bool `json:"is_persona"` - PersonaID string `json:"persona_id,omitempty"` - PersonaHandle string `json:"persona_handle,omitempty"` - PersonaScope string `json:"persona_scope,omitempty"` - PersonaAvatar string `json:"persona_avatar,omitempty"` - PersonaTeamName string `json:"persona_team_name,omitempty"` - - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` - Avatar string `json:"avatar,omitempty"` - SystemPrompt string `json:"system_prompt,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - ToolGrants []string `json:"tool_grants,omitempty"` - - Pricing *ModelPricing `json:"pricing,omitempty"` - - Scope string `json:"scope"` - OwnerID *string `json:"owner_id,omitempty"` - TeamName string `json:"team_name,omitempty"` - - Hidden bool `json:"hidden"` - SortOrder int `json:"sort_order"` - - ProviderStatus ProviderStatus `json:"provider_status,omitempty"` // health status from provider health (v0.22.3) -} - // ========================================= // JSON HELPERS // ========================================= @@ -796,16 +226,7 @@ func (m *JSONMap) Scan(src interface{}) error { return nil } -// ── Extensions ────────────────────────────── - // Extension tier constants -const ( - ExtTierBrowser = "browser" - ExtTierStarlark = "starlark" - ExtTierSidecar = "sidecar" -) - -// Extension represents an installed extension in the registry. type Extension struct { ID string `json:"id" db:"id"` ExtID string `json:"ext_id" db:"ext_id"` // manifest id @@ -937,114 +358,15 @@ type ResourceGrant struct { CreatedBy string `json:"created_by" db:"created_by"` } -// ── Knowledge Bases ──────────────────────────── - // KnowledgeBase is a named collection of documents with vector embeddings. -type KnowledgeBase struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - Scope string `json:"scope" db:"scope"` // global, team, personal - OwnerID *string `json:"owner_id,omitempty" db:"owner_id"` - TeamID *string `json:"team_id,omitempty" db:"team_id"` - EmbeddingConfig JSONMap `json:"embedding_config" db:"embedding_config"` - DocumentCount int `json:"document_count" db:"document_count"` - ChunkCount int `json:"chunk_count" db:"chunk_count"` - TotalBytes int64 `json:"total_bytes" db:"total_bytes"` - Discoverable bool `json:"discoverable" db:"discoverable"` - Status string `json:"status" db:"status"` // active, processing, error - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - // PersonaKB binds a knowledge base to a persona. -type PersonaKB struct { - PersonaID string `json:"persona_id" db:"persona_id"` - KBID string `json:"kb_id" db:"kb_id"` - AutoSearch bool `json:"auto_search" db:"auto_search"` - AddedAt time.Time `json:"added_at" db:"added_at"` - - // Joined fields (not in persona_knowledge_bases table) - KBName string `json:"kb_name,omitempty" db:"kb_name"` - DocumentCount int `json:"document_count,omitempty" db:"document_count"` - ChunkCount int `json:"chunk_count,omitempty" db:"chunk_count"` -} - // KBDocument is a single uploaded file within a knowledge base. -type KBDocument struct { - ID string `json:"id" db:"id"` - KBID string `json:"kb_id" db:"kb_id"` - Filename string `json:"filename" db:"filename"` - ContentType string `json:"content_type" db:"content_type"` - SizeBytes int64 `json:"size_bytes" db:"size_bytes"` - StorageKey string `json:"storage_key" db:"storage_key"` - ExtractedText *string `json:"-" db:"extracted_text"` - ChunkCount int `json:"chunk_count" db:"chunk_count"` - Status string `json:"status" db:"status"` // pending, chunking, embedding, ready, error - Error *string `json:"error,omitempty" db:"error"` - UploadedBy string `json:"uploaded_by" db:"uploaded_by"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - // KBChunk is a text segment from a document with its embedding vector. -type KBChunk struct { - ID string `json:"id" db:"id"` - KBID string `json:"kb_id" db:"kb_id"` - DocumentID string `json:"document_id" db:"document_id"` - ChunkIndex int `json:"chunk_index" db:"chunk_index"` - Content string `json:"content" db:"content"` - TokenCount int `json:"token_count" db:"token_count"` - Embedding []float64 `json:"-" db:"embedding"` // never serialized to API - Metadata JSONMap `json:"metadata" db:"metadata"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - // KBSearchResult is a single result from similarity search. -type KBSearchResult struct { - Content string `json:"content"` - Filename string `json:"source"` - KBName string `json:"kb"` - Similarity float64 `json:"similarity"` - Metadata JSONMap `json:"metadata,omitempty"` -} - // ChannelKB represents a knowledge base linked to a channel. -type ChannelKB struct { - KBID string `json:"kb_id" db:"kb_id"` - KBName string `json:"kb_name" db:"name"` - Enabled bool `json:"enabled" db:"enabled"` - DocumentCount int `json:"document_count" db:"document_count"` -} - -// ========================================= // PROVIDER HEALTH (v0.22.0) -// ========================================= // ProviderStatus represents the derived health state. -type ProviderStatus string - -const ( - StatusHealthy ProviderStatus = "healthy" - StatusDegraded ProviderStatus = "degraded" - StatusDown ProviderStatus = "down" - StatusUnknown ProviderStatus = "unknown" -) - -// ProviderHealthWindow is one hourly bucket of health metrics. -type ProviderHealthWindow struct { - ID string `json:"id" db:"id"` - ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"` - WindowStart time.Time `json:"window_start" db:"window_start"` - RequestCount int `json:"request_count" db:"request_count"` - ErrorCount int `json:"error_count" db:"error_count"` - TimeoutCount int `json:"timeout_count" db:"timeout_count"` - RateLimitCount int `json:"rate_limit_count" db:"rate_limit_count"` // v0.22.4 - TotalLatencyMs int64 `json:"total_latency_ms" db:"total_latency_ms"` - MaxLatencyMs int `json:"max_latency_ms" db:"max_latency_ms"` - LastError *string `json:"last_error,omitempty" db:"last_error"` - LastErrorAt *string `json:"last_error_at,omitempty" db:"last_error_at"` -} // AvgLatencyMs returns the average latency, or 0 if no requests. func (w *ProviderHealthWindow) AvgLatencyMs() int { @@ -1063,75 +385,13 @@ func (w *ProviderHealthWindow) ErrorRate() float64 { } // ProviderHealthSummary is the API response for a provider's current health. -type ProviderHealthSummary struct { - ProviderConfigID string `json:"provider_config_id"` - ProviderName string `json:"provider_name,omitempty"` - Status ProviderStatus `json:"status"` - RequestCount int `json:"request_count"` // last hour - ErrorRate float64 `json:"error_rate"` // last hour - ErrorCount int `json:"error_count"` // last hour - RateLimitCount int `json:"rate_limit_count"` // last hour (v0.22.4) - TimeoutCount int `json:"timeout_count"` // last hour - AvgLatencyMs int `json:"avg_latency_ms"` // last hour - MaxLatencyMs int `json:"max_latency_ms"` // last hour - LastError *string `json:"last_error,omitempty"` - LastErrorAt *string `json:"last_error_at,omitempty"` -} - // ToolHealthWindow tracks health of built-in tools (web_search, url_fetch, etc.) // in hourly buckets, analogous to ProviderHealthWindow. -type ToolHealthWindow struct { - ID string `json:"id" db:"id"` - ToolName string `json:"tool_name" db:"tool_name"` - WindowStart time.Time `json:"window_start" db:"window_start"` - RequestCount int `json:"request_count" db:"request_count"` - ErrorCount int `json:"error_count" db:"error_count"` - TotalLatencyMs int64 `json:"total_latency_ms" db:"total_latency_ms"` - MaxLatencyMs int `json:"max_latency_ms" db:"max_latency_ms"` - LastError *string `json:"last_error,omitempty" db:"last_error"` - LastErrorAt *string `json:"last_error_at,omitempty" db:"last_error_at"` -} - // ToolHealthSummary is the API response for a tool's health. -type ToolHealthSummary struct { - ToolName string `json:"tool_name"` - Status string `json:"status"` // healthy, degraded, down, unknown - RequestCount int `json:"request_count"` - ErrorRate float64 `json:"error_rate"` - AvgLatencyMs int `json:"avg_latency_ms"` -} - -// ========================================= // CAPABILITY OVERRIDES (v0.22.0) -// ========================================= // CapabilityOverride is an admin correction for a model's capabilities. -type CapabilityOverride struct { - ID string `json:"id" db:"id"` - ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - ModelID string `json:"model_id" db:"model_id"` - Field string `json:"field" db:"field"` - Value string `json:"value" db:"value"` - SetBy *string `json:"set_by,omitempty" db:"set_by"` - CreatedAt string `json:"created_at" db:"created_at"` -} - -// ========================================= // ROUTING POLICIES (v0.22.2) -// ========================================= // RoutingPolicy is one routing rule that controls how requests are // dispatched to provider configs. Stored in the routing_policies table. -type RoutingPolicy struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Scope string `json:"scope" db:"scope"` // "global" or "team" - TeamID *string `json:"team_id,omitempty" db:"team_id"` - Priority int `json:"priority" db:"priority"` // lower = first - Type string `json:"policy_type" db:"policy_type"` // provider_prefer, team_route, cost_limit, model_alias - Config JSONMap `json:"config" db:"config"` // type-specific JSONB - IsActive bool `json:"is_active" db:"is_active"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - diff --git a/server/models/models_git.go b/server/models/models_git.go deleted file mode 100644 index de897b9..0000000 --- a/server/models/models_git.go +++ /dev/null @@ -1,70 +0,0 @@ -package models - -import "time" - -// GitCredential stores encrypted git authentication credentials. -// The encrypted_data field contains JSON: { "token": "..." } for PAT, -// { "username": "...", "password": "..." } for basic auth, or -// { "private_key": "...", "passphrase": "..." } for SSH keys. -type GitCredential struct { - ID string `json:"id" db:"id"` - UserID string `json:"user_id" db:"user_id"` - Name string `json:"name" db:"name"` - AuthType string `json:"auth_type" db:"auth_type"` // https_pat, https_basic, ssh_key - EncryptedData []byte `json:"-" db:"encrypted_data"` // never expose - Nonce []byte `json:"-" db:"nonce"` // never expose - PublicKey string `json:"public_key,omitempty" db:"public_key"` - Fingerprint string `json:"fingerprint,omitempty" db:"fingerprint"` - PersonaID *string `json:"persona_id,omitempty" db:"persona_id"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} - -// GitCredentialSummary is the safe-to-expose version (no encrypted data). -type GitCredentialSummary struct { - ID string `json:"id"` - Name string `json:"name"` - AuthType string `json:"auth_type"` - PublicKey string `json:"public_key,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - PersonaID *string `json:"persona_id,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// Summary returns a safe version without encrypted fields. -func (c *GitCredential) Summary() GitCredentialSummary { - return GitCredentialSummary{ - ID: c.ID, - Name: c.Name, - AuthType: c.AuthType, - PublicKey: c.PublicKey, - Fingerprint: c.Fingerprint, - PersonaID: c.PersonaID, - CreatedAt: c.CreatedAt, - } -} - -// GitStatus represents the output of git status. -type GitStatus struct { - Branch string `json:"branch"` - Clean bool `json:"clean"` - Ahead int `json:"ahead"` - Behind int `json:"behind"` - Staged []GitFileStatus `json:"staged,omitempty"` - Modified []GitFileStatus `json:"modified,omitempty"` - Untracked []string `json:"untracked,omitempty"` -} - -// GitFileStatus represents a single file in git status output. -type GitFileStatus struct { - Path string `json:"path"` - Status string `json:"status"` // M, A, D, R, C, U -} - -// GitLogEntry represents a single commit in git log. -type GitLogEntry struct { - Hash string `json:"hash"` - ShortHash string `json:"short_hash"` - Author string `json:"author"` - Date time.Time `json:"date"` - Message string `json:"message"` -} diff --git a/server/models/models_memory.go b/server/models/models_memory.go deleted file mode 100644 index 6fd717f..0000000 --- a/server/models/models_memory.go +++ /dev/null @@ -1,48 +0,0 @@ -// ── models_memory.go ──────────────────────── -// Memory model for v0.18.0 — add these types to models/models.go -// (or keep as a separate file in the models package) - -package models - -import "time" - -// ── Memory Scope Constants ────────────────── - -const ( - MemoryScopeUser = "user" // personal facts, persists across all conversations - MemoryScopePersona = "persona" // shared across all users of a Persona - MemoryScopePersonaUser = "persona_user" // per-user within a Persona context -) - -// ── Memory Status Constants ───────────────── - -const ( - MemoryStatusActive = "active" - MemoryStatusPendingReview = "pending_review" - MemoryStatusArchived = "archived" -) - -// Memory represents a single fact or preference persisted across conversations. -type Memory struct { - ID string `json:"id" db:"id"` - Scope string `json:"scope" db:"scope"` - OwnerID string `json:"owner_id" db:"owner_id"` - UserID *string `json:"user_id,omitempty" db:"user_id"` - Key string `json:"key" db:"key"` - Value string `json:"value" db:"value"` - SourceChannelID *string `json:"source_channel_id,omitempty" db:"source_channel_id"` - Confidence float64 `json:"confidence" db:"confidence"` - Status string `json:"status" db:"status"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// MemoryFilter specifies which memories to retrieve. -type MemoryFilter struct { - Scope string // required: user, persona, persona_user - OwnerID string // required: user_id or persona_id - UserID *string // only for persona_user scope - Status string // default: "active" - Query string // optional: keyword filter on key+value - Limit int // max results (default 50) -} diff --git a/server/models/models_workspace.go b/server/models/models_workspace.go deleted file mode 100644 index b05a19e..0000000 --- a/server/models/models_workspace.go +++ /dev/null @@ -1,116 +0,0 @@ -package models - -import "time" - -// ========================================= -// WORKSPACES (v0.21.0) -// ========================================= - -// Workspace owner type constants. -const ( - WorkspaceOwnerUser = "user" - WorkspaceOwnerProject = "project" - WorkspaceOwnerChannel = "channel" - WorkspaceOwnerTeam = "team" -) - -// Workspace status constants. -const ( - WorkspaceStatusActive = "active" - WorkspaceStatusArchived = "archived" - WorkspaceStatusDeleting = "deleting" -) - -// Workspace represents a file workspace bound to an owner (user, project, channel, or team). -type Workspace struct { - BaseModel - OwnerType string `json:"owner_type" db:"owner_type"` - OwnerID string `json:"owner_id" db:"owner_id"` - Name string `json:"name" db:"name"` - RootPath string `json:"-" db:"root_path"` // never expose filesystem path - MaxBytes *int64 `json:"max_bytes,omitempty" db:"max_bytes"` - Status string `json:"status" db:"status"` - - IndexingEnabled bool `json:"indexing_enabled" db:"indexing_enabled"` - - // Git integration (v0.21.4) - GitRemoteURL *string `json:"git_remote_url,omitempty" db:"git_remote_url"` - GitBranch *string `json:"git_branch,omitempty" db:"git_branch"` - GitCredentialID *string `json:"git_credential_id,omitempty" db:"git_credential_id"` - GitLastSync *time.Time `json:"git_last_sync,omitempty" db:"git_last_sync"` - - // Computed fields (not DB columns) - FileCount int `json:"file_count,omitempty"` - TotalBytes int64 `json:"total_bytes,omitempty"` -} - -// WorkspacePatch holds optional fields for updating a workspace. -type WorkspacePatch struct { - Name *string `json:"name,omitempty"` - MaxBytes *int64 `json:"max_bytes,omitempty"` - Status *string `json:"status,omitempty"` - IndexingEnabled *bool `json:"indexing_enabled,omitempty"` - - // Git integration (v0.21.4) - GitRemoteURL *string `json:"git_remote_url,omitempty"` - GitBranch *string `json:"git_branch,omitempty"` - GitCredentialID *string `json:"git_credential_id,omitempty"` -} - -// WorkspaceFile represents metadata for a single file or directory in a workspace. -type WorkspaceFile struct { - ID string `json:"id" db:"id"` - WorkspaceID string `json:"workspace_id" db:"workspace_id"` - Path string `json:"path" db:"path"` - IsDirectory bool `json:"is_directory" db:"is_directory"` - ContentType string `json:"content_type,omitempty" db:"content_type"` - SizeBytes int64 `json:"size_bytes" db:"size_bytes"` - SHA256 string `json:"sha256,omitempty" db:"sha256"` - IndexStatus string `json:"index_status,omitempty" db:"index_status"` - ChunkCount int `json:"chunk_count,omitempty" db:"chunk_count"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// WorkspaceStats holds aggregate storage metrics for a workspace. -type WorkspaceStats struct { - WorkspaceID string `json:"workspace_id"` - FileCount int `json:"file_count"` - DirCount int `json:"dir_count"` - TotalBytes int64 `json:"total_bytes"` - MaxBytes *int64 `json:"max_bytes,omitempty"` -} - -// ========================================= -// WORKSPACE CHUNKS (v0.21.2) -// ========================================= - -// Workspace file index status constants. -const ( - IndexStatusPending = "pending" - IndexStatusIndexing = "indexing" - IndexStatusReady = "ready" - IndexStatusError = "error" - IndexStatusSkipped = "skipped" -) - -// WorkspaceChunk is a text segment from a workspace file with its embedding vector. -type WorkspaceChunk struct { - ID string `json:"id" db:"id"` - WorkspaceID string `json:"workspace_id" db:"workspace_id"` - FileID string `json:"file_id" db:"file_id"` - ChunkIndex int `json:"chunk_index" db:"chunk_index"` - Content string `json:"content" db:"content"` - TokenCount int `json:"token_count" db:"token_count"` - Embedding []float64 `json:"-" db:"embedding"` // never serialized to API - Metadata JSONMap `json:"metadata" db:"metadata"` -} - -// WorkspaceChunkResult is a single result from workspace similarity search. -type WorkspaceChunkResult struct { - FilePath string `json:"file_path"` - Content string `json:"content"` - Score float64 `json:"score"` - LineHint int `json:"line_hint,omitempty"` - Metadata JSONMap `json:"metadata,omitempty"` -} diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 189034c..6edeee0 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -25,237 +25,30 @@ var ErrSystemGroup = errors.New("system groups cannot be deleted") // Stores bundles all store interfaces for dependency injection. type Stores struct { - Providers ProviderStore - Catalog CatalogStore - Personas PersonaStore - Policies PolicyStore - UserSettings UserModelSettingsStore Users UserStore Teams TeamStore - Channels ChannelStore - Messages MessageStore Audit AuditStore - Notes NoteStore - NoteLinks NoteLinkStore GlobalConfig GlobalConfigStore - Usage UsageStore - Pricing PricingStore - Files FileStore - KnowledgeBases KnowledgeBaseStore Groups GroupStore ResourceGrants ResourceGrantStore - Memories MemoryStore - Projects ProjectStore Notifications NotificationStore NotifPrefs NotificationPreferenceStore - Workspaces WorkspaceStore - GitCredentials GitCredentialStore - CapOverrides CapabilityOverrideStore - RoutingPolicies RoutingPolicyStore Sessions SessionStore + Presence PresenceStore // v0.29.0: User online/offline status + Health HealthStore // v0.29.0: Provider health window management + Connections ConnectionStore // v0.38.1: Extension connection credentials + Dependencies DependencyStore // v0.38.2: Library package dependency graph Packages PackageStore // v0.28.7: Unified package registry (surfaces + extensions) Workflows WorkflowStore // v0.26.1: Workflow definitions + stages Tasks TaskStore // v0.27.1: Task scheduling + run history - Presence PresenceStore // v0.29.0: User online/offline status - PersonaGroups PersonaGroupStore // v0.29.0: Persona group (roster template) CRUD - Folders FolderStore // v0.29.0: Chat folder CRUD - Health HealthStore // v0.29.0: Provider health window management ExtPermissions ExtensionPermissionStore // v0.29.0: Extension declared/granted capabilities ExtData ExtDataStore // v0.29.2: Extension namespaced table catalog Tickets TicketStore // v0.32.0: WS auth tickets (PG-backed for cross-pod) RateLimits RateLimitStore // v0.32.0: Distributed rate limiting - Export ExportStore // v0.34.0: Data portability batch reads + GDPR ops - Connections ConnectionStore // v0.38.1: Extension connection credentials - Dependencies DependencyStore // v0.38.2: Library package dependency graph -} - -// ========================================= -// PROVIDER STORE -// ========================================= - -type ProviderStore interface { - Create(ctx context.Context, cfg *models.ProviderConfig) error - GetByID(ctx context.Context, id string) (*models.ProviderConfig, error) - Update(ctx context.Context, id string, patch models.ProviderConfigPatch) error - Delete(ctx context.Context, id string) error - - // Scoped queries - ListGlobal(ctx context.Context) ([]models.ProviderConfig, error) - ListForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) - ListForUser(ctx context.Context, userID string) ([]models.ProviderConfig, error) // personal scope - ListAccessible(ctx context.Context, userID string) ([]models.ProviderConfig, error) // all user can access - - // Access check - UserCanAccess(ctx context.Context, userID, configID string) (bool, error) - - // ── CS4 additions (v0.29.0) ── - - // DeletePersonalByOwner removes all personal-scope provider configs for a user. - DeletePersonalByOwner(ctx context.Context, ownerID string) (int64, error) - - // ── CS6 additions (v0.29.0) ── - - // ListAllForTeam returns all provider configs (including inactive) for a team. - ListAllForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) - - // DeleteByIDAndTeam deletes a team-scoped provider by ID and team. Returns rows affected. - DeleteByIDAndTeam(ctx context.Context, id, teamID string) (int64, error) - - // ── CS7a additions (v0.29.0) ── - - // FindFirstForUser returns the ID of the first active provider config - // accessible to a user (personal first, then global; excludes team). - // Returns sql.ErrNoRows if none found. - FindFirstForUser(ctx context.Context, userID string) (string, error) - - // LoadAccessible loads a provider config by ID, verifying the user has access - // (global, personal owned by user, or team the user belongs to). - // Returns sql.ErrNoRows if not found or not accessible. - LoadAccessible(ctx context.Context, configID, userID string) (*models.ProviderConfig, error) -} - -// ========================================= -// CATALOG STORE -// ========================================= - -type CatalogStore interface { - // Sync from provider API - UpsertFromSync(ctx context.Context, providerConfigID string, entries []CatalogSyncEntry) (added, updated int, err error) - - // Queries - GetByID(ctx context.Context, id string) (*models.CatalogEntry, error) - GetByModelID(ctx context.Context, providerConfigID, modelID string) (*models.CatalogEntry, error) - GetByModelIDAny(ctx context.Context, modelID string) (*models.CatalogEntry, error) // any provider, most recently synced - ListVisible(ctx context.Context) ([]models.CatalogEntry, error) - ListEnabledForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) - ListForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) - ListAll(ctx context.Context) ([]models.CatalogEntry, error) // everything (internal use) - ListAllGlobal(ctx context.Context) ([]models.CatalogEntry, error) // admin view — global-scope providers only - - // Visibility management - SetVisibility(ctx context.Context, id string, visibility string) error - BulkSetVisibility(ctx context.Context, providerConfigID string, visibility string) error - BulkSetVisibilityAll(ctx context.Context, visibility string) error - - // Delete - Delete(ctx context.Context, id string) error - DeleteForProvider(ctx context.Context, providerConfigID string) error - - // ── Mention resolution (v0.29.0 — moved from handler raw SQL) ── - - // FindEnabledByModelID finds an enabled catalog entry by exact model_id - // (case-insensitive). Returns (modelID, providerConfigID, nil). - // Prefers global > team > personal scope providers. - FindEnabledByModelID(ctx context.Context, modelID string) (string, string, error) - - // FindEnabledByModelIDPrefix finds by unambiguous model_id prefix. - // Returns (modelID, configID, matchCount, nil). - FindEnabledByModelIDPrefix(ctx context.Context, prefix string) (string, string, int, error) - - // ── CS2 additions (v0.29.0) ── - - // GetCapabilities returns the raw capabilities JSON for a specific provider+model. - GetCapabilities(ctx context.Context, modelID, configID string) ([]byte, error) - - // GetCapabilitiesAny returns capabilities for a model from any provider (most recent sync). - GetCapabilitiesAny(ctx context.Context, modelID string) ([]byte, error) - - // ── CS6 additions (v0.29.0) ── - - // ListTeamAvailable returns catalog entries with visibility 'enabled' or 'team' - // from active global providers. Used by team model selector. - ListTeamAvailable(ctx context.Context) ([]TeamAvailableModel, error) } // TeamAvailableModel is returned by CatalogStore.ListTeamAvailable. -type TeamAvailableModel struct { - ID string - ModelID string - DisplayName *string - Visibility string - Provider string - ProviderName string -} - // CatalogSyncEntry is the input format from provider FetchModels. -type CatalogSyncEntry struct { - ModelID string - DisplayName string - ModelType string // "chat", "embedding", "image" — from provider API - Capabilities models.ModelCapabilities - Pricing *models.ModelPricing -} - -// ========================================= -// PERSONA STORE -// ========================================= - -type PersonaStore interface { - Create(ctx context.Context, p *models.Persona) error - GetByID(ctx context.Context, id string) (*models.Persona, error) - Update(ctx context.Context, id string, patch models.PersonaPatch) error - Delete(ctx context.Context, id string) error - - // Scoped queries - ListForUser(ctx context.Context, userID string) ([]models.Persona, error) // all visible to user - ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error) - ListGlobal(ctx context.Context) ([]models.Persona, error) - - // Grants - SetGrants(ctx context.Context, personaID string, grants []models.Grant) error - GetGrants(ctx context.Context, personaID string) ([]models.Grant, error) - GetToolGrants(ctx context.Context, personaID string) ([]string, error) - - // Access check - UserCanAccess(ctx context.Context, userID, personaID string) (bool, error) - - // Knowledge base bindings - SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error - GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error) - GetKBIDs(ctx context.Context, personaID string) ([]string, error) - - // ── Mention resolution (v0.29.0 — moved from handler raw SQL) ── - - // FindActiveByHandle returns an active persona's ID by exact handle match (case-insensitive). - // Returns "" if not found. - FindActiveByHandle(ctx context.Context, handle string) (string, error) - - // FindActiveByHandlePrefix returns a persona ID by unambiguous handle prefix. - // Returns ("", count, nil) if ambiguous or no match. - FindActiveByHandlePrefix(ctx context.Context, prefix string) (string, int, error) - - // GetNameByID returns the persona's display name. Returns "" if not found. - GetNameByID(ctx context.Context, id string) (string, error) - - // GetNamesByIDs returns a map of persona ID → name for batch resolution. - GetNamesByIDs(ctx context.Context, ids []string) (map[string]string, error) - - // GetDisplayInfoByIDs returns name + avatar for batch sender resolution. - GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]UserDisplayInfo, error) -} - -// ========================================= -// POLICY STORE -// ========================================= - -type PolicyStore interface { - Get(ctx context.Context, key string) (string, error) - GetBool(ctx context.Context, key string) (bool, error) - Set(ctx context.Context, key, value string, updatedBy string) error - GetAll(ctx context.Context) (map[string]string, error) -} - -// ========================================= -// USER MODEL SETTINGS STORE -// ========================================= - -type UserModelSettingsStore interface { - GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) - GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) - Set(ctx context.Context, userID, modelID string, providerConfigID *string, patch models.UserModelSettingPatch) error - BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error -} - // ========================================= // USER STORE // ========================================= @@ -392,344 +185,11 @@ type TeamStore interface { MergeSettings(ctx context.Context, teamID, settingsJSON string) error } -// ========================================= -// CHANNEL STORE -// ========================================= - -type ChannelStore interface { - Create(ctx context.Context, ch *models.Channel) error - GetByID(ctx context.Context, id string) (*models.Channel, error) - Update(ctx context.Context, id string, fields map[string]interface{}) error - Delete(ctx context.Context, id string) error - ListForUser(ctx context.Context, userID string, opts ListOptions) ([]models.Channel, int, error) - Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Channel, int, error) - - // Cursor management (conversation forking) - GetCursor(ctx context.Context, channelID, userID string) (*models.ChannelCursor, error) - SetCursor(ctx context.Context, channelID, userID, leafID string) error - - // Channel models - SetModel(ctx context.Context, cm *models.ChannelModel) error - GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) - GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) - UpdateModel(ctx context.Context, id string, fields map[string]interface{}) error - DeleteModel(ctx context.Context, id string) error - - // Ownership check - UserOwns(ctx context.Context, channelID, userID string) (bool, error) - - // Channel participants (ICD §3.7) - AddParticipant(ctx context.Context, p *models.ChannelParticipant) error - ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) - GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error) - UpdateParticipantRole(ctx context.Context, id, role string) error - RemoveParticipant(ctx context.Context, id string) error - IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error) - GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error) - CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error) - - // Remove model roster entries auto-created by a persona participant - DeleteModelByPersona(ctx context.Context, channelID, personaID string) error - - // Workspace resolution: channel workspace_id > project workspace_id - ResolveWorkspaceID(ctx context.Context, channelID string) (string, error) - - // Admin: archived channel management (v0.23.2) - ListArchived(ctx context.Context, opts ListOptions) ([]ArchivedChannel, int, error) - Purge(ctx context.Context, id string) error // hard-delete; channel must be archived - - // ── Single-field helpers (v0.29.0 — moved from handler raw SQL) ── - - // GetAIMode returns the channel's ai_mode ("auto", "off", "mention_only"). - GetAIMode(ctx context.Context, channelID string) (string, error) - - // GetTypeAndTeamID returns (channel_type, team_id) in one query. - GetTypeAndTeamID(ctx context.Context, channelID string) (string, *string, error) - - // GetSystemPrompt returns the channel's system_prompt (may be nil). - GetSystemPrompt(ctx context.Context, channelID string) (*string, error) - - // GetDefaultModel returns the channel's model field (may be nil). - GetDefaultModel(ctx context.Context, channelID string) (*string, error) - - // TouchUpdatedAt bumps the channel's updated_at to now. - TouchUpdatedAt(ctx context.Context, channelID string) error - - // ListUserParticipantIDs returns user participant IDs, excluding excludeUserID. - ListUserParticipantIDs(ctx context.Context, channelID, excludeUserID string) ([]string, error) - - // ListPersonaParticipantIDs returns persona participant IDs ordered by created_at. - ListPersonaParticipantIDs(ctx context.Context, channelID string) ([]string, error) - - // GetLeaderPersonaID returns the persona group leader for a channel. - // Falls back to the first persona participant if no leader flag is set. - // Returns empty string if no persona participants exist. - GetLeaderPersonaID(ctx context.Context, channelID string) (string, error) - - // GetWorkflowInfo returns (workflow_id, current_stage) for workflow-type channels. - // Returns nil workflow_id if the channel is not a workflow channel. - GetWorkflowInfo(ctx context.Context, channelID string) (*string, int, error) - - // ── DM + lifecycle helpers (v0.29.0-cs1) ── - - // FindExistingDM returns the channel ID of an existing DM between two users. - // Returns "" if no DM exists. - FindExistingDM(ctx context.Context, userID1, userID2 string) (string, error) - - // GetUnreadCount returns the number of unread messages for a user in a channel. - GetUnreadCount(ctx context.Context, channelID, userID string) (int, error) - - // DeleteByOwner deletes a channel if the user is the owner. - // Returns rows affected (0 = not found or not owner). - DeleteByOwner(ctx context.Context, channelID, userID string) (int64, error) - - // ArchiveForRetention archives a channel and sets a purge_after timestamp. - ArchiveForRetention(ctx context.Context, channelID string, purgeAfter time.Time) error - - // ListPurgeable returns IDs of channels whose purge_after has passed. - ListPurgeable(ctx context.Context) ([]string, error) - - // MarkRead updates last_read_at and last_read_message_id for a user. - MarkRead(ctx context.Context, channelID, userID string) error - - // CountParticipantsByType returns count of participants of a given type. - CountParticipantsByType(ctx context.Context, channelID, pType string) (int, error) - - // CountAll returns the total number of channels. - CountAll(ctx context.Context) (int, error) - - // ── Workflow instance state (v0.29.0-cs3) ── - - // SetWorkflowInstance initializes workflow columns on a channel. - SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error - - // GetWorkflowStatus returns the full workflow state for a workflow channel. - GetWorkflowStatus(ctx context.Context, channelID string) (*WorkflowChannelStatus, error) - - // AdvanceWorkflowStage updates current_stage + stage_data + last_activity_at. - AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error - - // CompleteWorkflow sets workflow_status='completed' and ai_mode='off'. - CompleteWorkflow(ctx context.Context, channelID string, finalStage int, stageData json.RawMessage) error - - // CancelWorkflow sets workflow_status='cancelled' and ai_mode='off'. (v0.37.15) - CancelWorkflow(ctx context.Context, channelID string) error - - // RejectWorkflowToStage resets current_stage (no stage_data change). - RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error - - // GetStageData returns the current stage_data JSON from a channel. - GetStageData(ctx context.Context, channelID string) (json.RawMessage, error) - - // ── Background job helpers (v0.29.0-cs4) ── - - // MarkStaleWorkflows sets workflow_status='stale' on active workflow channels - // with last_activity_at before cutoff. Returns rows affected. - MarkStaleWorkflows(ctx context.Context, cutoff time.Time) (int64, error) - - // EnforceWorkflowRetention deletes completed workflow channels whose parent - // workflow has retention.mode="delete" and the retention period has elapsed. - // PG-only (uses JSON operators). Returns rows affected. No-op on SQLite. - EnforceWorkflowRetention(ctx context.Context) (int64, error) - - // GetTypeAndAllowAnonymous returns (type, allow_anonymous) for session auth. - GetTypeAndAllowAnonymous(ctx context.Context, channelID string) (string, bool, error) - - // ── CS5b additions (v0.29.0) ── - - // FindCompactionCandidates returns direct channels eligible for auto-compaction. - // Filters by activity gap, max age, minimum message count, and minimum total chars. - // PG-only (uses settings::text cast). Returns empty slice on SQLite. - FindCompactionCandidates(ctx context.Context, activityBefore, createdAfter time.Time, minMessages, minChars, limit int) ([]models.Channel, error) - - // ── CS7a additions (v0.29.0) ── - - // GetProviderConfigID returns the channel's provider_config_id (may be nil). - GetProviderConfigID(ctx context.Context, channelID string) (*string, error) - - // UserCanAccess checks if a user is the channel owner or a participant. - UserCanAccess(ctx context.Context, channelID, userID string) (bool, error) - - // ── CS7b additions (v0.29.0) ── - - // ListFiltered returns paginated channels with message counts, applying - // complex filters (types, folder, search, project). Includes channels - // owned by the user or where the user is a participant. - ListFiltered(ctx context.Context, userID string, filter ChannelListFilter) ([]ChannelListItem, int, error) - - // GetForUser loads a single channel with message count, verifying the - // user is the owner or a participant. Returns sql.ErrNoRows if not found. - GetForUser(ctx context.Context, channelID, userID string) (*ChannelListItem, error) - - // CreateFull atomically creates a channel, adds the owner as a participant, - // optionally adds DM partner participants, and creates a default channel_model. - // The channel model (ch) is populated with ID, CreatedAt, UpdatedAt on return. - // folder, tags, and aiMode are passed separately as they're not in models.Channel. - CreateFull(ctx context.Context, ch *models.Channel, folder string, tags []string, aiMode string, - ownerUserID string, dmPartnerIDs []string, defaultModel, defaultConfigID string) error - - // MergeSettings merges a JSON object into the channel's settings column. - MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error - - // ── Monitoring (v0.35.0) ── - - // ListByType returns all channels of a given type (e.g. "workflow"). - ListByType(ctx context.Context, channelType string) ([]models.Channel, error) -} - // ChannelListFilter holds filter options for ChannelStore.ListFiltered. -type ChannelListFilter struct { - ListOptions - Archived bool - Types []string // empty = all - Folder string - FolderID string - Search string // title ILIKE/LIKE - ProjectID string // "none" = NULL, uuid = specific, "" = no filter -} - // ChannelListItem is returned by ListFiltered and GetForUser. -type ChannelListItem struct { - ID string - UserID string - Title string - Type string - AiMode string - Topic *string - Description *string - Model *string - ProviderConfigID *string - SystemPrompt *string - IsArchived bool - IsPinned bool - Folder *string - FolderID *string - ProjectID *string - WorkspaceID *string - Tags []string - Settings json.RawMessage - MessageCount int - UnreadCount int - CreatedAt string - UpdatedAt string - CreatedAtTime time.Time `json:"-"` // SQLite scan target - UpdatedAtTime time.Time `json:"-"` // SQLite scan target -} - // ArchivedChannel is a view model for the admin archived channels list. -type ArchivedChannel struct { - ID string `json:"id"` - Title string `json:"title"` - Type string `json:"type"` - OwnerName string `json:"owner_name"` - UpdatedAt string `json:"updated_at"` - MessageCount int `json:"message_count"` -} - -// ========================================= -// MESSAGE STORE -// ========================================= - -type MessageStore interface { - Create(ctx context.Context, m *models.Message) error - GetByID(ctx context.Context, id string) (*models.Message, error) - Update(ctx context.Context, id string, fields map[string]interface{}) error - Delete(ctx context.Context, id string) error // soft delete - ListForChannel(ctx context.Context, channelID string, opts ListOptions) ([]models.Message, error) - - // Tree operations - GetChildren(ctx context.Context, parentID string) ([]models.Message, error) - GetSiblings(ctx context.Context, messageID string) ([]models.Message, error) - GetPathToRoot(ctx context.Context, messageID string) ([]models.Message, error) - GetNextSiblingIndex(ctx context.Context, parentID string) (int, error) - - // Count - CountForChannel(ctx context.Context, channelID string) (int, error) - - // ── Tree operations (v0.29.0 — moved from treepath package) ── - - // GetActiveLeaf returns the cursor's active_leaf_id for a user in a channel. - // Falls back to the chronologically latest live message if no cursor exists. - GetActiveLeaf(ctx context.Context, channelID, userID string) (*string, error) - - // GetPathToLeaf walks from a leaf up to root via parent_id, returning - // root-first order with sibling counts and sender info resolved. - GetPathToLeaf(ctx context.Context, channelID, leafID string) ([]PathMessage, error) - - // GetActivePath is GetActiveLeaf + GetPathToLeaf combined. - GetActivePath(ctx context.Context, channelID, userID string) ([]PathMessage, error) - - // GetSiblingsList returns all live siblings of a message with the current index. - GetSiblingsList(ctx context.Context, messageID string) ([]SiblingInfo, int, error) - - // GetSiblingCount returns how many live siblings share the same parent. - GetSiblingCount(ctx context.Context, channelID string, parentID *string) (int, error) - - // FindLeafFromMessage walks down from a message to find the deepest descendant. - FindLeafFromMessage(ctx context.Context, messageID string) (string, error) - - // NextSiblingIndexForParent returns the next sibling_index for children of parentID. - NextSiblingIndexForParent(ctx context.Context, channelID string, parentID *string) (int, error) - - // HasPersonaMessages checks if any assistant message in the channel was - // generated by a persona. - HasPersonaMessages(ctx context.Context, channelID string) (bool, error) - - // CreateWithCursor inserts a message, updates the cursor, and touches - // channel.updated_at. Handles dialect-specific ID generation internally. - CreateWithCursor(ctx context.Context, m *models.Message, cursorUserID string) error - - // ResolveSenderInfo batch-resolves sender_name and sender_avatar for - // user and persona participants. Updates the PathMessage slice in-place. - ResolveSenderInfo(ctx context.Context, path []PathMessage) error - - // CountAll returns the total number of messages (for admin stats). - CountAll(ctx context.Context) (int, error) - - // ── CS5c additions (v0.29.0) ── - - // SearchInChannel performs full-text search within a single channel's messages. - // PG uses to_tsvector/ts_headline; SQLite falls back to LIKE. - // roleFilter is "user", "assistant", or "" for all. - SearchInChannel(ctx context.Context, channelID, query, roleFilter string, limit int) ([]ChannelSearchResult, error) - - // ── CS7a additions (v0.29.0) ── - - // ListWithSenderInfo returns paginated messages with sender name/avatar - // resolved via JOINs to users and personas tables. - ListWithSenderInfo(ctx context.Context, channelID string, limit, offset int) ([]MessageWithSender, int, error) - - // GetParentAndRole loads just the parent_id and role of a message, - // verifying it belongs to the given channel and is not deleted. - GetParentAndRole(ctx context.Context, messageID, channelID string) (parentID *string, role string, err error) -} - // MessageWithSender is returned by MessageStore.ListWithSenderInfo. -type MessageWithSender struct { - ID string - ChannelID string - Role string - Content string - Model *string - TokensUsed *int - ParentID *string - SiblingIndex int - ParticipantType *string - ParticipantID *string - SenderName *string - SenderAvatar *string - CreatedAt string -} - // ChannelSearchResult is returned by MessageStore.SearchInChannel. -type ChannelSearchResult struct { - MessageID string - Role string - Excerpt string // ts_headline or content substring - Rank float64 - Timestamp time.Time -} - // ========================================= // AUDIT STORE // ========================================= @@ -751,80 +211,8 @@ type AuditListOptions struct { TeamID string // CS6: scope to team members } -// ========================================= -// NOTE STORE -// ========================================= - -type NoteStore interface { - Create(ctx context.Context, n *models.Note) error - GetByID(ctx context.Context, id string) (*models.Note, error) - Update(ctx context.Context, id string, fields map[string]interface{}) error - Delete(ctx context.Context, id string) error - ListForUser(ctx context.Context, userID string, opts NoteListOptions) ([]models.Note, int, error) - Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Note, int, error) - SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error) - BulkDelete(ctx context.Context, ids []string, userID string) (int, error) - - // ── CS5c additions (v0.29.0) ── - - // SetEmbedding stores a pgvector embedding on a note. PG-only; SQLite no-ops. - SetEmbedding(ctx context.Context, noteID, vecStr string) error - - // SearchKeyword performs full-text keyword search with ranking and headlines. - // PG uses ts_rank/ts_headline; SQLite falls back to LIKE. - SearchKeyword(ctx context.Context, userID, query string, limit int) ([]NoteSearchResult, error) - - // SearchSemantic performs vector similarity search against note embeddings. - // PG-only; SQLite returns empty results. - SearchSemantic(ctx context.Context, userID, vecStr string, limit int) ([]NoteSearchResult, error) - - // ListFolders returns distinct folder paths and note counts for a user. - ListFolders(ctx context.Context, userID string) ([]FolderInfo, error) -} - -type NoteListOptions struct { - ListOptions - FolderPath string - Tag string - TeamID string -} - // NoteSearchResult is returned by NoteStore.SearchKeyword and SearchSemantic. -type NoteSearchResult struct { - ID string - Title string - FolderPath string - Tags []string - Excerpt string // truncated content preview - Headline string // ts_headline markup (empty on SQLite) - Rank float64 // ts_rank or similarity score -} - // FolderInfo is returned by NoteStore.ListFolders. -type FolderInfo struct { - Path string - Count int -} - -// ========================================= -// NOTE LINK STORE -// ========================================= - -// NoteLinkStore manages wikilink edges between notes. -type NoteLinkStore interface { - // ReplaceLinks deletes existing links for sourceNoteID and inserts new ones. - ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error - - // ResolveByTitle sets target_note_id on dangling links matching the title for a user. - ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error - - // Backlinks returns notes that link to the given note. - Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error) - - // Graph returns all nodes and edges for a user's note graph. - Graph(ctx context.Context, userID string) (*models.NoteGraph, error) -} - // ========================================= // GLOBAL CONFIG STORE // ========================================= @@ -853,116 +241,6 @@ type GlobalConfigStore interface { GetString(ctx context.Context, key string) (string, error) } -// ========================================= -// USAGE STORE -// ========================================= - -type UsageStore interface { - Log(ctx context.Context, entry *models.UsageEntry) error - CountRecentByRole(ctx context.Context, userID, role string, since time.Time) (int, error) - QueryByUser(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error) - QueryByUserPersonal(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error) - QueryByTeam(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error) - QueryByTeamProviders(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error) - QueryByModel(ctx context.Context, opts UsageQueryOptions) ([]models.UsageAggregate, error) - GetTotals(ctx context.Context, opts UsageQueryOptions) (*models.UsageTotals, error) - GetTeamProviderTotals(ctx context.Context, teamID string, opts UsageQueryOptions) (*models.UsageTotals, error) - GetPersonalTotals(ctx context.Context, userID string, opts UsageQueryOptions) (*models.UsageTotals, error) -} - -type UsageQueryOptions struct { - Since *time.Time - Until *time.Time - GroupBy string // "day", "model", "user", "provider" - ExcludeBYOK bool // true for admin views — filters provider_scope != 'personal' - Limit int -} - -// ========================================= -// PRICING STORE -// ========================================= - -type PricingStore interface { - GetForModel(ctx context.Context, providerConfigID, modelID string) (*models.PricingEntry, error) - Upsert(ctx context.Context, entry *models.PricingEntry) error - UpsertFromCatalog(ctx context.Context, providerConfigID, modelID string, pricing *models.ModelPricing) error - List(ctx context.Context) ([]models.PricingEntry, error) - Delete(ctx context.Context, providerConfigID, modelID string) error -} - -// ========================================= -// FILE STORE -// ========================================= - -type FileStore interface { - Create(ctx context.Context, f *models.File) error - GetByID(ctx context.Context, id string) (*models.File, error) - GetByChannel(ctx context.Context, channelID string, origin string) ([]models.File, error) - GetByMessage(ctx context.Context, messageID string) ([]models.File, error) - GetByProject(ctx context.Context, projectID string) ([]models.File, error) - GetByUser(ctx context.Context, userID string, page, perPage int) ([]models.File, int, error) // paginated, returns total - SetMessageID(ctx context.Context, fileID, messageID string) error - UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error - SetExtractedText(ctx context.Context, id string, text string) error - Delete(ctx context.Context, id string) (*models.File, error) // returns deleted row for storage cleanup - DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys - UserUsageBytes(ctx context.Context, userID string) (int64, error) - ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.File, error) - - // ── CS5c additions (v0.29.0) ── - - // UpdateStorageKey sets the storage key after initial file creation. - UpdateStorageKey(ctx context.Context, id, key string) error -} - - - -// ========================================= -// KNOWLEDGE BASE STORE -// ========================================= - -type KnowledgeBaseStore interface { - // KB CRUD - Create(ctx context.Context, kb *models.KnowledgeBase) error - GetByID(ctx context.Context, id string) (*models.KnowledgeBase, error) - Update(ctx context.Context, id string, fields map[string]interface{}) error - Delete(ctx context.Context, id string) error - - // Scoped listing - ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) - ListPersonal(ctx context.Context, userID string) ([]models.KnowledgeBase, error) - - // Documents - CreateDocument(ctx context.Context, doc *models.KBDocument) error - GetDocument(ctx context.Context, id string) (*models.KBDocument, error) - ListDocuments(ctx context.Context, kbID string) ([]models.KBDocument, error) - UpdateDocumentStatus(ctx context.Context, id string, status string, errMsg *string) error - UpdateDocumentText(ctx context.Context, id string, text string, chunkCount int) error - UpdateDocumentStorageKey(ctx context.Context, id string, storageKey string) error - DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) // returns for storage cleanup - - // Chunks - InsertChunks(ctx context.Context, chunks []models.KBChunk) error - DeleteChunksForDocument(ctx context.Context, documentID string) error - SimilaritySearch(ctx context.Context, kbIDs []string, queryVec []float64, - threshold float64, limit int) ([]models.KBSearchResult, error) - - // Channel links - SetChannelKBs(ctx context.Context, channelID string, kbIDs []string) error - GetChannelKBs(ctx context.Context, channelID string) ([]models.ChannelKB, error) - GetActiveKBIDs(ctx context.Context, channelID string, userID string, - teamIDs []string) ([]string, error) // enabled + user has access - GetActiveKBIDsWithPersona(ctx context.Context, channelID string, userID string, - teamIDs []string, personaID string) ([]string, error) // includes persona-bound KBs - ListDiscoverable(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) - - // Stats — recount document_count/chunk_count/total_bytes from child tables - UpdateStats(ctx context.Context, kbID string) error - - // Discoverable management - SetDiscoverable(ctx context.Context, kbID string, discoverable bool) error -} - // ========================================= // GROUP STORE (v0.16.0) // ========================================= @@ -1030,104 +308,6 @@ type NotificationPreferenceStore interface { Delete(ctx context.Context, userID, notifType string) error } -// ========================================= -// WORKSPACE STORE (v0.21.0) -// ========================================= - -type WorkspaceStore interface { - // CRUD - Create(ctx context.Context, w *models.Workspace) error - GetByID(ctx context.Context, id string) (*models.Workspace, error) - Update(ctx context.Context, id string, patch models.WorkspacePatch) error - Delete(ctx context.Context, id string) error - - // Ownership lookup - GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) - ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) - - // File index - UpsertFile(ctx context.Context, f *models.WorkspaceFile) error - DeleteFile(ctx context.Context, workspaceID, path string) error - DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error - GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) - ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) - DeleteAllFiles(ctx context.Context, workspaceID string) error - - // Stats - GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) - - // Chunks (v0.21.2 — workspace indexing) - InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error - DeleteChunksByFile(ctx context.Context, fileID string) error - SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error) - UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error - - // Git (v0.21.4) - SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error -} - -// ========================================= -// GIT CREDENTIAL STORE (v0.21.4) -// ========================================= - -type GitCredentialStore interface { - Create(ctx context.Context, cred *models.GitCredential) error - GetByID(ctx context.Context, id string) (*models.GitCredential, error) - ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) - Delete(ctx context.Context, id, userID string) error -} - -// ========================================= -// CAPABILITY OVERRIDES (v0.22.0) -// ========================================= - -type CapabilityOverrideStore interface { - // Set creates or updates an override for (provider, model, field). - Set(ctx context.Context, o *models.CapabilityOverride) error - - // Delete removes a specific override. - Delete(ctx context.Context, id string) error - - // ListForModel returns all overrides for a model ID (across all providers + global). - ListForModel(ctx context.Context, modelID string) ([]models.CapabilityOverride, error) - - // ListForProviderModel returns overrides for a specific provider+model combination. - ListForProviderModel(ctx context.Context, providerConfigID, modelID string) ([]models.CapabilityOverride, error) - - // ListAll returns every override (admin view). - ListAll(ctx context.Context) ([]models.CapabilityOverride, error) - - // DeleteForProvider removes all overrides for a provider (cascade cleanup). - DeleteForProvider(ctx context.Context, providerConfigID string) error -} - -// ========================================= -// ROUTING POLICY STORE (v0.22.2) -// ========================================= - -type RoutingPolicyStore interface { - // Create adds a new routing policy. - Create(ctx context.Context, p *models.RoutingPolicy) error - - // Update modifies an existing routing policy. - Update(ctx context.Context, p *models.RoutingPolicy) error - - // Delete removes a routing policy by ID. - Delete(ctx context.Context, id string) error - - // GetByID returns a single policy. - GetByID(ctx context.Context, id string) (*models.RoutingPolicy, error) - - // ListActive returns all active policies, ordered by priority ASC. - ListActive(ctx context.Context) ([]models.RoutingPolicy, error) - - // ListAll returns all policies (including inactive), for admin listing. - ListAll(ctx context.Context) ([]models.RoutingPolicy, error) - - // ListForTeam returns active policies applicable to a team (team-scoped + global). - ListForTeam(ctx context.Context, teamID string) ([]models.RoutingPolicy, error) -} - // ========================================= // SESSION STORE (v0.24.3) // ========================================= @@ -1146,42 +326,6 @@ type SessionStore interface { DeleteExpired(ctx context.Context, olderThan time.Time) (int64, error) } -// ========================================= -// PERSONA GROUP STORE (v0.29.0) -// ========================================= - -// PersonaGroupStore manages persona_groups and their members. -type PersonaGroupStore interface { - // CRUD - List(ctx context.Context, ownerID string) ([]models.PersonaGroup, error) - Get(ctx context.Context, id, ownerID string) (*models.PersonaGroup, error) - Create(ctx context.Context, g *models.PersonaGroup) error - Update(ctx context.Context, id string, fields map[string]interface{}) error - Delete(ctx context.Context, id, ownerID string) (int64, error) - - // Ownership check - GetOwnerID(ctx context.Context, id string) (string, error) - - // Members - AddMember(ctx context.Context, groupID, personaID string, isLeader bool) error - RemoveMember(ctx context.Context, memberID, groupID string) error - ListMembers(ctx context.Context, groupID string) ([]models.PersonaGroupMember, error) -} - -// ========================================= -// FOLDER STORE (v0.29.0) -// ========================================= - -// FolderStore manages user chat folders. -type FolderStore interface { - List(ctx context.Context, userID string) ([]models.Folder, error) - Create(ctx context.Context, f *models.Folder) error - Update(ctx context.Context, folderID, userID string, name string, sortOrder *int, parentID **string) (int64, error) - Delete(ctx context.Context, folderID, userID string) (int64, error) - // UnassignChannels removes folder_id from all channels in this folder. - UnassignChannels(ctx context.Context, folderID, userID string) error -} - // ========================================= // HEALTH STORE (v0.29.0) // ========================================= @@ -1210,162 +354,6 @@ type PresenceStore interface { GetStatuses(ctx context.Context, userIDs []string, threshold time.Time) (map[string]string, error) } -// ========================================= -// EXPORT STORE (v0.34.0) -// ========================================= - -// ExportStore provides batch-read queries for data export, import, -// and GDPR operations. These methods are optimized for bulk retrieval -// without N+1 queries. Embeddings are excluded at query level. -type ExportStore interface { - // ── User-scoped export ── - - // UserChannels returns all channels owned by userID. - UserChannels(ctx context.Context, userID string) ([]models.Channel, error) - - // UserMessages returns all non-deleted messages in the given channels, ordered by created_at ASC. - UserMessages(ctx context.Context, channelIDs []string) ([]models.Message, error) - - // UserChannelParticipants returns participants for the given channels. - UserChannelParticipants(ctx context.Context, channelIDs []string) ([]models.ChannelParticipant, error) - - // UserChannelModels returns channel_models for the given channels. - UserChannelModels(ctx context.Context, channelIDs []string) ([]models.ChannelModel, error) - - // UserChannelCursors returns cursors for the user in the given channels. - UserChannelCursors(ctx context.Context, userID string, channelIDs []string) ([]models.ChannelCursor, error) - - // UserNotes returns all notes owned by userID. - UserNotes(ctx context.Context, userID string) ([]models.Note, error) - - // UserNoteLinks returns note_links with source_note_id for the given note IDs. - UserNoteLinks(ctx context.Context, noteIDs []string) ([]models.ExportNoteLink, error) - - // UserMemories returns active memories for userID (scope=user). - UserMemories(ctx context.Context, userID string) ([]models.Memory, error) - - // UserProjects returns personal-scope projects owned by userID. - UserProjects(ctx context.Context, userID string) ([]models.Project, error) - - // UserProjectChannels returns project_channels for the given projects. - UserProjectChannels(ctx context.Context, projectIDs []string) ([]models.ProjectChannel, error) - - // UserProjectKBs returns project_knowledge_bases for the given projects. - UserProjectKBs(ctx context.Context, projectIDs []string) ([]models.ProjectKB, error) - - // UserProjectNotes returns project_notes for the given projects. - UserProjectNotes(ctx context.Context, projectIDs []string) ([]models.ProjectNote, error) - - // UserWorkspaces returns workspaces owned by user (owner_type=user). - UserWorkspaces(ctx context.Context, userID string) ([]models.Workspace, error) - - // UserWorkspaceFiles returns file index for the given workspaces. - UserWorkspaceFiles(ctx context.Context, workspaceIDs []string) ([]models.WorkspaceFile, error) - - // UserFiles returns file metadata for files uploaded by userID. - UserFiles(ctx context.Context, userID string) ([]models.File, error) - - // UserFolders returns all folders for userID. - UserFolders(ctx context.Context, userID string) ([]models.Folder, error) - - // UserModelSettings returns user_model_settings for userID. - UserModelSettings(ctx context.Context, userID string) ([]models.UserModelSetting, error) - - // UserNotifPrefs returns notification preferences for userID. - UserNotifPrefs(ctx context.Context, userID string) ([]models.NotificationPreference, error) - - // UserUsageEntries returns usage entries for userID (last 90 days). - UserUsageEntries(ctx context.Context, userID string) ([]models.UsageEntry, error) - - // UserPersonaGroups returns persona groups owned by userID. - UserPersonaGroups(ctx context.Context, userID string) ([]models.PersonaGroup, error) - - // UserPersonaGroupMembers returns members for the given groups. - UserPersonaGroupMembers(ctx context.Context, groupIDs []string) ([]models.PersonaGroupMember, error) - - // ── Team-scoped export ── - - // TeamChannels returns all channels belonging to teamID. - TeamChannels(ctx context.Context, teamID string) ([]models.Channel, error) - - // TeamMembers returns team membership records. - TeamMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) - - // TeamPersonas returns team-scoped personas. - TeamPersonas(ctx context.Context, teamID string) ([]models.Persona, error) - - // TeamPersonaKBs returns persona_knowledge_bases for given persona IDs. - TeamPersonaKBs(ctx context.Context, personaIDs []string) ([]models.PersonaKB, error) - - // TeamKnowledgeBases returns team-scoped knowledge bases. - TeamKnowledgeBases(ctx context.Context, teamID string) ([]models.KnowledgeBase, error) - - // TeamKBDocuments returns KB document metadata for given KB IDs (no chunks/embeddings). - TeamKBDocuments(ctx context.Context, kbIDs []string) ([]models.KBDocument, error) - - // TeamWorkflows returns team-scoped workflows. - TeamWorkflows(ctx context.Context, teamID string) ([]models.Workflow, error) - - // TeamWorkflowVersions returns versions for given workflow IDs. - TeamWorkflowVersions(ctx context.Context, workflowIDs []string) ([]models.WorkflowVersion, error) - - // TeamWorkflowStages returns stages for given workflow IDs. - TeamWorkflowStages(ctx context.Context, workflowIDs []string) ([]models.WorkflowStage, error) - - // TeamGroups returns team-scoped groups. - TeamGroups(ctx context.Context, teamID string) ([]models.Group, error) - - // TeamGroupMembers returns members for given group IDs. - TeamGroupMembers(ctx context.Context, groupIDs []string) ([]models.GroupMember, error) - - // TeamResourceGrants returns resource grants for team resources. - TeamResourceGrants(ctx context.Context, teamID string) ([]models.ResourceGrant, error) - - // TeamProjects returns team-scoped projects. - TeamProjects(ctx context.Context, teamID string) ([]models.Project, error) - - // ── Import (CS1) ── - // Each ImportXxx method inserts entities preserving their original IDs. - // Uses INSERT ON CONFLICT DO NOTHING (PG) / INSERT OR IGNORE (SQLite) - // to skip duplicates. Returns (imported, skipped) counts. - - ImportFolders(ctx context.Context, folders []models.Folder) (int, int, error) - ImportProjects(ctx context.Context, projects []models.Project) (int, int, error) - ImportWorkspaces(ctx context.Context, workspaces []models.Workspace) (int, int, error) - ImportChannels(ctx context.Context, channels []models.Channel) (int, int, error) - ImportChannelParticipants(ctx context.Context, cps []models.ChannelParticipant) (int, int, error) - ImportChannelModels(ctx context.Context, cms []models.ChannelModel) (int, int, error) - ImportChannelCursors(ctx context.Context, cursors []models.ChannelCursor) (int, int, error) - ImportMessages(ctx context.Context, msgs []models.Message) (int, int, error) - ImportNotes(ctx context.Context, notes []models.Note) (int, int, error) - ImportNoteLinks(ctx context.Context, links []models.ExportNoteLink) (int, int, error) - ImportMemories(ctx context.Context, mems []models.Memory) (int, int, error) - ImportProjectChannels(ctx context.Context, pcs []models.ProjectChannel) (int, int, error) - ImportProjectKBs(ctx context.Context, pks []models.ProjectKB) (int, int, error) - ImportProjectNotes(ctx context.Context, pns []models.ProjectNote) (int, int, error) - ImportWorkspaceFiles(ctx context.Context, wfs []models.WorkspaceFile) (int, int, error) - ImportFiles(ctx context.Context, files []models.File) (int, int, error) - ImportUserModelSettings(ctx context.Context, ums []models.UserModelSetting) (int, int, error) - ImportNotifPrefs(ctx context.Context, nps []models.NotificationPreference) (int, int, error) - ImportPersonaGroups(ctx context.Context, pgs []models.PersonaGroup) (int, int, error) - ImportPersonaGroupMembers(ctx context.Context, pgms []models.PersonaGroupMember) (int, int, error) - - // ── GDPR ── - - // CountActiveAdmins returns the number of active admin users. - CountActiveAdmins(ctx context.Context) (int, error) - - // AnonymizeUser replaces PII with "deleted-user-{hash}" placeholders. - AnonymizeUser(ctx context.Context, userID string, anonHash string) error - - // SoftDeleteUserData cascade-deletes/soft-deletes all user-owned data. - // Returns entity counts by type for the audit trail. - SoftDeleteUserData(ctx context.Context, userID string) (map[string]int64, error) - - // DeleteUserTokens revokes all refresh tokens and WS tickets. - DeleteUserTokens(ctx context.Context, userID string) error -} - // ========================================= // SHARED TYPES // ========================================= diff --git a/server/store/postgres/cap_override.go b/server/store/postgres/cap_override.go deleted file mode 100644 index 4c7b68f..0000000 --- a/server/store/postgres/cap_override.go +++ /dev/null @@ -1,85 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - - "switchboard-core/models" -) - -// CapOverrideStore implements store.CapabilityOverrideStore for Postgres. -type CapOverrideStore struct { - db *sql.DB -} - -func NewCapOverrideStore(db *sql.DB) *CapOverrideStore { - return &CapOverrideStore{db: db} -} - -func (s *CapOverrideStore) Set(ctx context.Context, o *models.CapabilityOverride) error { - _, err := s.db.ExecContext(ctx, ` - INSERT INTO capability_overrides (provider_config_id, model_id, field, value, set_by) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (provider_config_id, model_id, field) DO UPDATE SET - value = EXCLUDED.value, - set_by = EXCLUDED.set_by, - created_at = now() - `, o.ProviderConfigID, o.ModelID, o.Field, o.Value, o.SetBy) - return err -} - -func (s *CapOverrideStore) Delete(ctx context.Context, id string) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM capability_overrides WHERE id = $1`, id) - return err -} - -func (s *CapOverrideStore) ListForModel(ctx context.Context, modelID string) ([]models.CapabilityOverride, error) { - return s.query(ctx, ` - SELECT id, provider_config_id, model_id, field, value, set_by, created_at - FROM capability_overrides - WHERE model_id = $1 - ORDER BY provider_config_id NULLS LAST, field - `, modelID) -} - -func (s *CapOverrideStore) ListForProviderModel(ctx context.Context, providerConfigID, modelID string) ([]models.CapabilityOverride, error) { - return s.query(ctx, ` - SELECT id, provider_config_id, model_id, field, value, set_by, created_at - FROM capability_overrides - WHERE model_id = $1 AND (provider_config_id = $2 OR provider_config_id IS NULL) - ORDER BY provider_config_id NULLS LAST, field - `, modelID, providerConfigID) -} - -func (s *CapOverrideStore) ListAll(ctx context.Context) ([]models.CapabilityOverride, error) { - return s.query(ctx, ` - SELECT id, provider_config_id, model_id, field, value, set_by, created_at - FROM capability_overrides - ORDER BY model_id, provider_config_id NULLS LAST, field - `) -} - -func (s *CapOverrideStore) DeleteForProvider(ctx context.Context, providerConfigID string) error { - _, err := s.db.ExecContext(ctx, ` - DELETE FROM capability_overrides WHERE provider_config_id = $1 - `, providerConfigID) - return err -} - -func (s *CapOverrideStore) query(ctx context.Context, q string, args ...interface{}) ([]models.CapabilityOverride, error) { - rows, err := s.db.QueryContext(ctx, q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.CapabilityOverride - for rows.Next() { - var o models.CapabilityOverride - if err := rows.Scan(&o.ID, &o.ProviderConfigID, &o.ModelID, &o.Field, &o.Value, &o.SetBy, &o.CreatedAt); err != nil { - return nil, err - } - result = append(result, o) - } - return result, rows.Err() -} diff --git a/server/store/postgres/catalog.go b/server/store/postgres/catalog.go deleted file mode 100644 index bc86940..0000000 --- a/server/store/postgres/catalog.go +++ /dev/null @@ -1,338 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "time" - - "switchboard-core/models" - "switchboard-core/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). -// -// Runs in a single transaction: one SELECT to identify existing models, -// then N upserts via a prepared INSERT ON CONFLICT statement, one COMMIT. -// Previously this was N×2 auto-committed queries (SELECT + INSERT/UPDATE per model), -// which caused 300+ round-trips and WAL flushes on a typical Venice sync. -func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID string, entries []store.CatalogSyncEntry) (added, updated int, err error) { - if len(entries) == 0 { - return 0, 0, nil - } - - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return 0, 0, fmt.Errorf("begin tx: %w", err) - } - defer tx.Rollback() // no-op after commit - - // Pre-fetch existing model_ids for this provider to track added vs updated. - existing := make(map[string]bool) - rows, err := tx.QueryContext(ctx, - "SELECT model_id FROM model_catalog WHERE provider_config_id = $1", - providerConfigID) - if err != nil { - return 0, 0, fmt.Errorf("list existing: %w", err) - } - for rows.Next() { - var mid string - if err := rows.Scan(&mid); err != nil { - rows.Close() - return 0, 0, fmt.Errorf("scan existing: %w", err) - } - existing[mid] = true - } - rows.Close() - if err := rows.Err(); err != nil { - return 0, 0, fmt.Errorf("list existing: %w", err) - } - - // Prepare the upsert statement once, execute N times. - // INSERT creates new rows with visibility='disabled' (secure by default). - // ON CONFLICT updates metadata but preserves visibility (admin controls that). - stmt, err := tx.PrepareContext(ctx, ` - INSERT INTO model_catalog (provider_config_id, model_id, display_name, - model_type, capabilities, pricing, visibility, last_synced_at) - VALUES ($1, $2, $3, $4, $5, $6, 'disabled', $7) - ON CONFLICT (provider_config_id, model_id) DO UPDATE SET - display_name = EXCLUDED.display_name, - model_type = EXCLUDED.model_type, - capabilities = EXCLUDED.capabilities, - pricing = EXCLUDED.pricing, - last_synced_at = EXCLUDED.last_synced_at`) - if err != nil { - return 0, 0, fmt.Errorf("prepare upsert: %w", err) - } - defer stmt.Close() - - now := time.Now() - for _, e := range entries { - capsJSON := ToJSON(e.Capabilities) - pricingJSON := ToJSON(e.Pricing) - - modelType := e.ModelType - if modelType == "" { - modelType = "chat" - } - - if _, err := stmt.ExecContext(ctx, - providerConfigID, e.ModelID, e.DisplayName, - modelType, capsJSON, pricingJSON, now, - ); err != nil { - return added, updated, fmt.Errorf("upsert %s: %w", e.ModelID, err) - } - - if existing[e.ModelID] { - updated++ - } else { - added++ - } - } - - if err := tx.Commit(); err != nil { - return 0, 0, fmt.Errorf("commit: %w", 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 = $1", 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 = $1 AND model_id = $2", 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 personas 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 = $1 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 = true - 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 = $1 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 = $1 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 = $1 WHERE id = $2", 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 = $1 WHERE provider_config_id = $2", - visibility, providerConfigID) - return err -} - -func (s *CatalogStore) BulkSetVisibilityAll(ctx context.Context, visibility string) error { - _, err := DB.ExecContext(ctx, - `UPDATE model_catalog SET visibility = $1 - 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 = $1", 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 = $1", 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 sql.NullTime - err := row.Scan( - &e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, &e.ModelType, - &capsJSON, &pricingJSON, &e.Visibility, &lastSynced, - &e.CreatedAt, &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) - } - if lastSynced.Valid { - e.LastSyncedAt = &lastSynced.Time - } - 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 sql.NullTime - err := rows.Scan( - &e.ID, &e.ProviderConfigID, &e.ModelID, &displayName, &e.ModelType, - &capsJSON, &pricingJSON, &e.Visibility, &lastSynced, - &e.CreatedAt, &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) - } - if lastSynced.Valid { - e.LastSyncedAt = &lastSynced.Time - } - result = append(result, e) - } - return result, rows.Err() -} - -// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *CatalogStore) GetCapabilities(ctx context.Context, modelID, configID string) ([]byte, error) { - var capsJSON []byte - err := DB.QueryRowContext(ctx, ` - SELECT capabilities FROM model_catalog - WHERE model_id = $1 AND provider_config_id = $2 - `, modelID, configID).Scan(&capsJSON) - if err != nil { - return nil, err - } - return capsJSON, nil -} - -func (s *CatalogStore) GetCapabilitiesAny(ctx context.Context, modelID string) ([]byte, error) { - var capsJSON []byte - err := DB.QueryRowContext(ctx, ` - SELECT capabilities FROM model_catalog - WHERE model_id = $1 ORDER BY last_synced_at DESC LIMIT 1 - `, modelID).Scan(&capsJSON) - if err != nil { - return nil, err - } - return capsJSON, nil -} - -// ── CS6 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *CatalogStore) ListTeamAvailable(ctx context.Context) ([]store.TeamAvailableModel, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT mc.id, mc.model_id, mc.display_name, mc.visibility, - ac.provider, ac.name AS provider_name - FROM model_catalog mc - JOIN provider_configs ac ON mc.provider_config_id = ac.id - WHERE mc.visibility IN ('enabled', 'team') - AND ac.is_active = true AND ac.scope = 'global' - ORDER BY ac.name, mc.model_id - `) - if err != nil { - return nil, err - } - defer rows.Close() - - results := make([]store.TeamAvailableModel, 0) - for rows.Next() { - var m store.TeamAvailableModel - if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility, - &m.Provider, &m.ProviderName); err != nil { - continue - } - results = append(results, m) - } - return results, rows.Err() -} diff --git a/server/store/postgres/catalog_mention.go b/server/store/postgres/catalog_mention.go deleted file mode 100644 index 726f381..0000000 --- a/server/store/postgres/catalog_mention.go +++ /dev/null @@ -1,57 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" -) - -// ── Mention resolution (v0.29.0) ──────────────────────────────────────── - -func (s *CatalogStore) FindEnabledByModelID(ctx context.Context, modelID string) (string, string, error) { - var foundModelID, configID string - err := DB.QueryRowContext(ctx, ` - SELECT mc.model_id, mc.provider_config_id - FROM model_catalog mc - JOIN provider_configs pc ON pc.id = mc.provider_config_id - WHERE LOWER(mc.model_id) = LOWER($1) - AND mc.visibility = 'enabled' - AND pc.is_active = true - ORDER BY - CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END - LIMIT 1 - `, modelID).Scan(&foundModelID, &configID) - if err == sql.ErrNoRows { - return "", "", nil - } - return foundModelID, configID, err -} - -func (s *CatalogStore) FindEnabledByModelIDPrefix(ctx context.Context, prefix string) (string, string, int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(DISTINCT mc.model_id) - FROM model_catalog mc - JOIN provider_configs pc ON pc.id = mc.provider_config_id - WHERE LOWER(mc.model_id) LIKE LOWER($1) - AND mc.visibility = 'enabled' - AND pc.is_active = true - `, prefix+"%").Scan(&count) - if err != nil { - return "", "", 0, err - } - if count != 1 { - return "", "", count, nil - } - var modelID, configID string - err = DB.QueryRowContext(ctx, ` - SELECT mc.model_id, mc.provider_config_id - FROM model_catalog mc - JOIN provider_configs pc ON pc.id = mc.provider_config_id - WHERE LOWER(mc.model_id) LIKE LOWER($1) - AND mc.visibility = 'enabled' - AND pc.is_active = true - ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END - LIMIT 1 - `, prefix+"%").Scan(&modelID, &configID) - return modelID, configID, 1, err -} diff --git a/server/store/postgres/channel.go b/server/store/postgres/channel.go deleted file mode 100644 index a8f0732..0000000 --- a/server/store/postgres/channel.go +++ /dev/null @@ -1,1034 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/lib/pq" - - "switchboard-core/models" - "switchboard-core/store" -) - -type ChannelStore struct{} - -func NewChannelStore() *ChannelStore { return &ChannelStore{} } - -func (s *ChannelStore) Create(ctx context.Context, ch *models.Channel) error { - return DB.QueryRowContext(ctx, ` - INSERT INTO channels (user_id, title, description, type, model, system_prompt, - provider_config_id, is_archived, is_pinned, folder_id, team_id, settings) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) - RETURNING id, created_at, updated_at`, - ch.UserID, ch.Title, ch.Description, ch.Type, ch.Model, ch.SystemPrompt, - models.NullString(ch.ProviderConfigID), ch.IsArchived, ch.IsPinned, - models.NullString(ch.FolderID), models.NullString(ch.TeamID), ToJSON(ch.Settings), - ).Scan(&ch.ID, &ch.CreatedAt, &ch.UpdatedAt) -} - -func (s *ChannelStore) GetByID(ctx context.Context, id string) (*models.Channel, error) { - var ch models.Channel - var providerConfigID, folderID, teamID sql.NullString - var desc sql.NullString - var settingsJSON []byte - err := DB.QueryRowContext(ctx, ` - SELECT id, user_id, title, description, type, model, system_prompt, - provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, - created_at, updated_at - FROM channels WHERE id = $1`, id).Scan( - &ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, &ch.SystemPrompt, - &providerConfigID, &ch.IsArchived, &ch.IsPinned, &folderID, &teamID, &settingsJSON, - &ch.CreatedAt, &ch.UpdatedAt, - ) - if err != nil { - return nil, err - } - ch.Description = NullableString(desc) - ch.ProviderConfigID = NullableStringPtr(providerConfigID) - ch.FolderID = NullableStringPtr(folderID) - ch.TeamID = NullableStringPtr(teamID) - json.Unmarshal(settingsJSON, &ch.Settings) - return &ch, nil -} - -func (s *ChannelStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { - b := NewUpdate("channels") - for k, v := range fields { - if k == "settings" || k == "tags" { - b.SetJSON(k, v) - } else { - b.Set(k, v) - } - } - if !b.HasSets() { - return nil - } - b.Where("id", id) - _, err := b.Exec(DB) - return err -} - -func (s *ChannelStore) Delete(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, "DELETE FROM channels WHERE id = $1", id) - return err -} - -func (s *ChannelStore) ListForUser(ctx context.Context, userID string, opts store.ListOptions) ([]models.Channel, int, error) { - // Count - var total int - DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels WHERE user_id = $1", userID).Scan(&total) - - b := NewSelect( - "id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at", - "channels", - ).Where("user_id = ?", userID) - - if opts.Sort == "" { - b.OrderBy("updated_at", "DESC") - } - b.Paginate(opts) - - q, args := b.Build() - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var result []models.Channel - for rows.Next() { - var ch models.Channel - var providerConfigID, folderID, teamID, desc sql.NullString - var settingsJSON []byte - err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, - &ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned, - &folderID, &teamID, &settingsJSON, &ch.CreatedAt, &ch.UpdatedAt) - if err != nil { - return nil, 0, err - } - ch.Description = NullableString(desc) - ch.ProviderConfigID = NullableStringPtr(providerConfigID) - ch.FolderID = NullableStringPtr(folderID) - ch.TeamID = NullableStringPtr(teamID) - json.Unmarshal(settingsJSON, &ch.Settings) - result = append(result, ch) - } - return result, total, rows.Err() -} - -func (s *ChannelStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Channel, int, error) { - // Simple title search for now — will add full-text when search feature lands - b := NewSelect( - "id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at", - "channels", - ).Where("user_id = ?", userID).Where("title ILIKE ?", "%"+query+"%") - b.OrderBy("updated_at", "DESC") - b.Paginate(opts) - - var total int - DB.QueryRowContext(ctx, - "SELECT COUNT(*) FROM channels WHERE user_id = $1 AND title ILIKE $2", - userID, "%"+query+"%").Scan(&total) - - q, args := b.Build() - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var result []models.Channel - for rows.Next() { - var ch models.Channel - var providerConfigID, folderID, teamID, desc sql.NullString - var settingsJSON []byte - err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, - &ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned, - &folderID, &teamID, &settingsJSON, &ch.CreatedAt, &ch.UpdatedAt) - if err != nil { - return nil, 0, err - } - ch.Description = NullableString(desc) - ch.ProviderConfigID = NullableStringPtr(providerConfigID) - ch.FolderID = NullableStringPtr(folderID) - ch.TeamID = NullableStringPtr(teamID) - json.Unmarshal(settingsJSON, &ch.Settings) - result = append(result, ch) - } - return result, total, rows.Err() -} - -func (s *ChannelStore) GetCursor(ctx context.Context, channelID, userID string) (*models.ChannelCursor, error) { - var c models.ChannelCursor - var leafID sql.NullString - err := DB.QueryRowContext(ctx, ` - SELECT id, channel_id, user_id, active_leaf_id, updated_at - FROM channel_cursors WHERE channel_id = $1 AND user_id = $2`, - channelID, userID).Scan(&c.ID, &c.ChannelID, &c.UserID, &leafID, &c.UpdatedAt) - if err != nil { - return nil, err - } - c.ActiveLeafID = NullableStringPtr(leafID) - return &c, nil -} - -func (s *ChannelStore) SetCursor(ctx context.Context, channelID, userID, leafID string) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) - VALUES ($1, $2, $3) - ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = $3, updated_at = NOW()`, - channelID, userID, leafID) - return err -} - -func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error { - // For persona entries, use the persona-aware path - if cm.PersonaID != nil && *cm.PersonaID != "" { - return s.SetPersonaModel(ctx, cm) - } - // Nullable provider_config_id: empty string → SQL NULL (UUID FK) - var provCfgID interface{} = cm.ProviderConfigID - if cm.ProviderConfigID == "" { - provCfgID = nil - } - // Raw model upsert (no persona) — matches idx_channel_models_raw partial index - _, err := DB.ExecContext(ctx, ` - INSERT INTO channel_models (channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET - display_name = $4, system_prompt = $5, settings = $6, is_default = $7`, - cm.ChannelID, cm.ModelID, provCfgID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault) - return err -} - -// SetPersonaModel inserts or updates a persona's channel model roster entry. -// Uses the idx_channel_models_persona partial index (one per persona per channel). -func (s *ChannelStore) SetPersonaModel(ctx context.Context, cm *models.ChannelModel) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO channel_models (channel_id, model_id, provider_config_id, persona_id, display_name, system_prompt, settings, is_default) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (channel_id, persona_id) WHERE persona_id IS NOT NULL DO UPDATE SET - model_id = $2, provider_config_id = $3, display_name = $5, system_prompt = $6, settings = $7, is_default = $8`, - cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.PersonaID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault) - return err -} - -func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id::text, ''), - COALESCE(cm.persona_id::text, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''), - COALESCE(cm.system_prompt, ''), cm.is_default - FROM channel_models cm - LEFT JOIN personas p ON p.id = cm.persona_id - WHERE cm.channel_id = $1`, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.ChannelModel - for rows.Next() { - var cm models.ChannelModel - var personaID string - if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID, - &personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil { - return nil, err - } - if personaID != "" { - cm.PersonaID = &personaID - } - result = append(result, cm) - } - return result, rows.Err() -} - -func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) { - var cm models.ChannelModel - var personaID string - err := DB.QueryRowContext(ctx, ` - SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id::text, ''), - COALESCE(cm.persona_id::text, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''), - COALESCE(cm.system_prompt, ''), cm.is_default - FROM channel_models cm - LEFT JOIN personas p ON p.id = cm.persona_id - WHERE cm.id = $1`, id).Scan( - &cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID, - &personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault) - if err != nil { - return nil, err - } - if personaID != "" { - cm.PersonaID = &personaID - } - return &cm, nil -} - -func (s *ChannelStore) UpdateModel(ctx context.Context, id string, fields map[string]interface{}) error { - if len(fields) == 0 { - return nil - } - // Build SET clause dynamically - sets := make([]string, 0, len(fields)) - args := make([]interface{}, 0, len(fields)+1) - i := 1 - for col, val := range fields { - sets = append(sets, col+" = $"+fmt.Sprintf("%d", i)) - args = append(args, val) - i++ - } - args = append(args, id) - query := "UPDATE channel_models SET " + strings.Join(sets, ", ") + " WHERE id = $" + fmt.Sprintf("%d", i) - _, err := DB.ExecContext(ctx, query, args...) - return err -} - -func (s *ChannelStore) DeleteModel(ctx context.Context, id string) error { - res, err := DB.ExecContext(ctx, `DELETE FROM channel_models WHERE id = $1`, id) - if err != nil { - return err - } - n, _ := res.RowsAffected() - if n == 0 { - return sql.ErrNoRows - } - return nil -} - -func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) (bool, error) { - var exists bool - err := DB.QueryRowContext(ctx, - "SELECT EXISTS(SELECT 1 FROM channels WHERE id = $1 AND user_id = $2)", - channelID, userID).Scan(&exists) - return exists, err -} - -// ResolveWorkspaceID returns the effective workspace for a channel. -// Resolution: channel.workspace_id > project.workspace_id. -// Returns empty string if no workspace is bound. -func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string) (string, error) { - var wsID sql.NullString - err := DB.QueryRowContext(ctx, ` - SELECT COALESCE(c.workspace_id, p.workspace_id) - FROM channels c - LEFT JOIN project_channels pc ON pc.channel_id = c.id - LEFT JOIN projects p ON p.id = pc.project_id - WHERE c.id = $1 - LIMIT 1`, channelID).Scan(&wsID) - if err != nil { - if err == sql.ErrNoRows { - return "", nil - } - return "", err - } - return NullableString(wsID), nil -} - -// ── Channel Participants (ICD §3.7) ────────── - -func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelParticipant) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO channel_participants (channel_id, participant_type, participant_id, role, display_name, avatar_url) - VALUES ($1, $2, $3, $4, $5, $6)`, - p.ChannelID, p.ParticipantType, p.ParticipantID, p.Role, p.DisplayName, p.AvatarURL) - return err -} - -func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT cp.id, cp.channel_id, cp.participant_type, cp.participant_id, cp.role, - COALESCE(NULLIF(cp.display_name,''), NULLIF(u.display_name,''), u.username) AS display_name, - COALESCE(NULLIF(cp.avatar_url,''), u.avatar_url) AS avatar_url, - cp.joined_at - FROM channel_participants cp - LEFT JOIN users u ON cp.participant_type = 'user' AND cp.participant_id = u.id - WHERE cp.channel_id = $1 ORDER BY cp.joined_at`, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.ChannelParticipant - for rows.Next() { - var p models.ChannelParticipant - if err := rows.Scan(&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID, - &p.Role, &p.DisplayName, &p.AvatarURL, &p.JoinedAt); err != nil { - return nil, err - } - result = append(result, p) - } - return result, rows.Err() -} - -func (s *ChannelStore) GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error) { - var p models.ChannelParticipant - err := DB.QueryRowContext(ctx, ` - SELECT id, channel_id, participant_type, participant_id, role, - display_name, avatar_url, joined_at - FROM channel_participants WHERE id = $1`, id).Scan( - &p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID, - &p.Role, &p.DisplayName, &p.AvatarURL, &p.JoinedAt) - if err != nil { - return nil, err - } - return &p, nil -} - -func (s *ChannelStore) UpdateParticipantRole(ctx context.Context, id, role string) error { - _, err := DB.ExecContext(ctx, `UPDATE channel_participants SET role = $1 WHERE id = $2`, role, id) - return err -} - -func (s *ChannelStore) RemoveParticipant(ctx context.Context, id string) error { - res, err := DB.ExecContext(ctx, `DELETE FROM channel_participants WHERE id = $1`, id) - if err != nil { - return err - } - n, _ := res.RowsAffected() - if n == 0 { - return sql.ErrNoRows - } - return nil -} - -func (s *ChannelStore) IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error) { - var exists bool - err := DB.QueryRowContext(ctx, ` - SELECT EXISTS(SELECT 1 FROM channel_participants - WHERE channel_id = $1 AND participant_type = $2 AND participant_id = $3)`, - channelID, pType, pID).Scan(&exists) - return exists, err -} - -func (s *ChannelStore) GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error) { - var role string - err := DB.QueryRowContext(ctx, ` - SELECT role FROM channel_participants - WHERE channel_id = $1 AND participant_type = $2 AND participant_id = $3`, - channelID, pType, pID).Scan(&role) - return role, err -} - -func (s *ChannelStore) CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM channel_participants WHERE channel_id = $1 AND role = $2`, - channelID, role).Scan(&count) - return count, err -} - -func (s *ChannelStore) DeleteModelByPersona(ctx context.Context, channelID, personaID string) error { - _, err := DB.ExecContext(ctx, - `DELETE FROM channel_models WHERE channel_id = $1 AND persona_id = $2`, - channelID, personaID) - return err -} - -// ── Admin: Archived Channel Management ────── - -func (s *ChannelStore) ListArchived(ctx context.Context, opts store.ListOptions) ([]store.ArchivedChannel, int, error) { - // Count - var total int - DB.QueryRowContext(ctx, - `SELECT COUNT(*) FROM channels WHERE is_archived = true`).Scan(&total) - - rows, err := DB.QueryContext(ctx, ` - SELECT c.id, c.title, c.type, COALESCE(u.username, '') AS owner_name, - c.updated_at, - (SELECT COUNT(*) FROM messages WHERE channel_id = c.id) AS message_count - FROM channels c - LEFT JOIN users u ON u.id = c.user_id - WHERE c.is_archived = true - ORDER BY c.updated_at DESC - LIMIT $1 OFFSET $2`, opts.Limit, opts.Offset) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var channels []store.ArchivedChannel - for rows.Next() { - var ch store.ArchivedChannel - if rows.Scan(&ch.ID, &ch.Title, &ch.Type, &ch.OwnerName, &ch.UpdatedAt, &ch.MessageCount) == nil { - channels = append(channels, ch) - } - } - if channels == nil { - channels = []store.ArchivedChannel{} - } - return channels, total, rows.Err() -} - -func (s *ChannelStore) Purge(ctx context.Context, id string) error { - // Verify the channel is actually archived - var isArchived bool - err := DB.QueryRowContext(ctx, - `SELECT is_archived FROM channels WHERE id = $1`, id).Scan(&isArchived) - if err != nil { - return fmt.Errorf("channel not found") - } - if !isArchived { - return fmt.Errorf("channel must be archived before purging") - } - - // Hard delete (CASCADE removes messages, participants, models) - _, err = DB.ExecContext(ctx, `DELETE FROM channels WHERE id = $1`, id) - return err -} -// ── CS1 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *ChannelStore) FindExistingDM(ctx context.Context, userID1, userID2 string) (string, error) { - var channelID string - err := DB.QueryRowContext(ctx, ` - SELECT cp1.channel_id FROM channel_participants cp1 - JOIN channel_participants cp2 ON cp1.channel_id = cp2.channel_id - JOIN channels c ON c.id = cp1.channel_id - WHERE c.type = 'dm' - AND cp1.participant_type = 'user' AND cp1.participant_id = $1 - AND cp2.participant_type = 'user' AND cp2.participant_id = $2 - LIMIT 1 - `, userID1, userID2).Scan(&channelID) - if err == sql.ErrNoRows { - return "", nil - } - return channelID, err -} - -func (s *ChannelStore) GetUnreadCount(ctx context.Context, channelID, userID string) (int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM messages m - JOIN channel_participants cp ON cp.channel_id = m.channel_id - WHERE cp.channel_id = $1 - AND cp.participant_type = 'user' AND cp.participant_id = $2 - AND m.created_at > cp.last_read_at - `, channelID, userID).Scan(&count) - return count, err -} - -func (s *ChannelStore) DeleteByOwner(ctx context.Context, channelID, userID string) (int64, error) { - result, err := DB.ExecContext(ctx, - `DELETE FROM channels WHERE id = $1 AND user_id = $2`, - channelID, userID) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -func (s *ChannelStore) ArchiveForRetention(ctx context.Context, channelID string, purgeAfter time.Time) error { - _, err := DB.ExecContext(ctx, ` - UPDATE channels SET is_archived = true, purge_after = $2, updated_at = NOW() - WHERE id = $1 - `, channelID, purgeAfter) - return err -} - -func (s *ChannelStore) ListPurgeable(ctx context.Context) ([]string, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id FROM channels WHERE purge_after IS NOT NULL AND purge_after <= NOW() - `) - if err != nil { - return nil, err - } - defer rows.Close() - var ids []string - for rows.Next() { - var id string - if rows.Scan(&id) == nil { - ids = append(ids, id) - } - } - return ids, rows.Err() -} - -func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error { - // Update last_read_at - _, err := DB.ExecContext(ctx, ` - UPDATE channel_participants - SET last_read_at = NOW() - WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2 - `, channelID, userID) - if err != nil { - return nil // participant may not exist for legacy chats - } - - // Best-effort: update last_read_message_id - var latestMsgID *string - _ = DB.QueryRowContext(ctx, ` - SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1 - `, channelID).Scan(&latestMsgID) - if latestMsgID != nil { - _, _ = DB.ExecContext(ctx, ` - UPDATE channel_participants - SET last_read_message_id = $1 - WHERE channel_id = $2 AND participant_type = 'user' AND participant_id = $3 - `, *latestMsgID, channelID, userID) - } - - return nil -} - -func (s *ChannelStore) CountParticipantsByType(ctx context.Context, channelID, pType string) (int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM channel_participants - WHERE channel_id = $1 AND participant_type = $2 - `, channelID, pType).Scan(&count) - return count, err -} - -// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *ChannelStore) CountAll(ctx context.Context) (int, error) { - var count int - err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&count) - return count, err -} - -// ── Workflow instance state (v0.29.0-cs3) ─────────────────────────────── - -func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error { - now := time.Now().UTC() - _, err := DB.ExecContext(ctx, ` - UPDATE channels - SET workflow_id = $1, workflow_version = $2, current_stage = 0, - stage_data = $3, workflow_status = $4, last_activity_at = $5, - stage_entered_at = $6 - WHERE id = $7 - `, workflowID, version, stageData, status, now, now, channelID) - return err -} - -func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string) (*store.WorkflowChannelStatus, error) { - var ws store.WorkflowChannelStatus - var stageData []byte - err := DB.QueryRowContext(ctx, ` - SELECT workflow_id, workflow_version, current_stage, - COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'), - last_activity_at, stage_entered_at - FROM channels WHERE id = $1 AND type = 'workflow' - `, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion, - &ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt, &ws.StageEnteredAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - ws.StageData = stageData - return &ws, nil -} - -func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error { - now := time.Now().UTC() - _, err := DB.ExecContext(ctx, ` - UPDATE channels - SET current_stage = $1, stage_data = $2, last_activity_at = $3, - stage_entered_at = $4 - WHERE id = $5 - `, nextStage, stageData, now, now, channelID) - return err -} - -func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, finalStage int, stageData json.RawMessage) error { - _, err := DB.ExecContext(ctx, ` - UPDATE channels - SET current_stage = $1, workflow_status = 'completed', - stage_data = $2, last_activity_at = $3, ai_mode = 'off' - WHERE id = $4 - `, finalStage, stageData, time.Now().UTC(), channelID) - return err -} - -func (s *ChannelStore) CancelWorkflow(ctx context.Context, channelID string) error { - _, err := DB.ExecContext(ctx, ` - UPDATE channels - SET workflow_status = 'cancelled', ai_mode = 'off', last_activity_at = $1 - WHERE id = $2 AND type = 'workflow' - `, time.Now().UTC(), channelID) - return err -} - -func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error { - now := time.Now().UTC() - _, err := DB.ExecContext(ctx, ` - UPDATE channels SET current_stage = $1, last_activity_at = $2, - stage_entered_at = $3 WHERE id = $4 - `, stage, now, now, channelID) - return err -} - -func (s *ChannelStore) GetStageData(ctx context.Context, channelID string) (json.RawMessage, error) { - var data json.RawMessage - err := DB.QueryRowContext(ctx, ` - SELECT COALESCE(stage_data, '{}') FROM channels WHERE id = $1 - `, channelID).Scan(&data) - return data, err -} - -// ── Background job helpers (v0.29.0-cs4) ──────────────────────────────── - -func (s *ChannelStore) MarkStaleWorkflows(ctx context.Context, cutoff time.Time) (int64, error) { - result, err := DB.ExecContext(ctx, ` - UPDATE channels - SET workflow_status = 'stale' - WHERE type = 'workflow' - AND workflow_status = 'active' - AND last_activity_at < $1 - `, cutoff) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -func (s *ChannelStore) EnforceWorkflowRetention(ctx context.Context) (int64, error) { - result, err := DB.ExecContext(ctx, ` - DELETE FROM channels - WHERE type = 'workflow' - AND workflow_status IN ('completed', 'archived') - AND workflow_id IS NOT NULL - AND workflow_id IN ( - SELECT id FROM workflows - WHERE retention IS NOT NULL - AND retention->>'mode' = 'delete' - AND (retention->>'delete_after_days')::int > 0 - AND channels.last_activity_at < now() - ((retention->>'delete_after_days')::int || ' days')::interval - ) - `) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -func (s *ChannelStore) GetTypeAndAllowAnonymous(ctx context.Context, channelID string) (string, bool, error) { - var chType string - var allowAnon bool - err := DB.QueryRowContext(ctx, - `SELECT type, allow_anonymous FROM channels WHERE id = $1`, channelID).Scan(&chType, &allowAnon) - return chType, allowAnon, err -} - -// ── CS5b additions (v0.29.0) ──────────────────────────────────────────── - -func (s *ChannelStore) FindCompactionCandidates(ctx context.Context, activityBefore, createdAfter time.Time, minMessages, minChars, limit int) ([]models.Channel, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT c.id, c.user_id, COALESCE(c.model, ''), COALESCE(c.settings::text, '{}'), - COUNT(m.id) AS msg_count, - COALESCE(SUM(LENGTH(m.content)), 0) AS total_chars - FROM channels c - JOIN messages m ON m.channel_id = c.id AND m.deleted_at IS NULL - WHERE c.type = 'direct' - AND c.is_archived = false - AND c.updated_at < $1 - AND c.updated_at > $2 - GROUP BY c.id - HAVING COUNT(m.id) >= $3 - AND COALESCE(SUM(LENGTH(m.content)), 0) > $4 - ORDER BY c.updated_at DESC - LIMIT $5 - `, activityBefore, createdAfter, minMessages, minChars, limit) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.Channel - for rows.Next() { - var ch models.Channel - var settingsRaw string - var msgCount, totalChars int - if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Model, &settingsRaw, &msgCount, &totalChars); err != nil { - continue - } - ch.Settings = models.JSONMap{} - _ = json.Unmarshal([]byte(settingsRaw), &ch.Settings) - result = append(result, ch) - } - if result == nil { - result = []models.Channel{} - } - return result, rows.Err() -} - -// ── CS7a additions (v0.29.0) ──────────────────────────────────────────── - -func (s *ChannelStore) GetProviderConfigID(ctx context.Context, channelID string) (*string, error) { - var configID sql.NullString - err := DB.QueryRowContext(ctx, - `SELECT provider_config_id FROM channels WHERE id = $1`, channelID).Scan(&configID) - if err != nil { - return nil, err - } - return NullableStringPtr(configID), nil -} - -func (s *ChannelStore) UserCanAccess(ctx context.Context, channelID, userID string) (bool, error) { - var ok bool - err := DB.QueryRowContext(ctx, ` - SELECT EXISTS( - SELECT 1 FROM channels WHERE id = $1 AND user_id = $2 - UNION ALL - SELECT 1 FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2 - LIMIT 1 - )`, channelID, userID).Scan(&ok) - return ok, err -} - -// ── CS7b additions (v0.29.0) ──────────────────────────────────────────── - -const channelListCols = `c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic, - c.description, c.model, c.provider_config_id, - c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.folder_id, c.project_id, c.workspace_id, - c.tags, c.settings, - COALESCE(mc.cnt, 0) AS message_count, - c.created_at, c.updated_at` - -const channelListFrom = `channels c - LEFT JOIN ( - SELECT channel_id, COUNT(*) AS cnt FROM messages WHERE deleted_at IS NULL GROUP BY channel_id - ) mc ON mc.channel_id = c.id` - -func (s *ChannelStore) ListFiltered(ctx context.Context, userID string, f store.ChannelListFilter) ([]store.ChannelListItem, int, error) { - b := NewSelect(channelListCols, channelListFrom) - b.Where("(c.user_id = ? OR c.id IN (SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = ?))", userID, userID) - b.Where("c.is_archived = ?", f.Archived) - - if len(f.Types) == 1 { - b.Where("c.type = ?", f.Types[0]) - } else if len(f.Types) > 1 { - placeholders := make([]string, len(f.Types)) - for i, t := range f.Types { - b.argIdx++ - placeholders[i] = fmt.Sprintf("$%d", b.argIdx) - b.args = append(b.args, t) - } - b.WhereRaw("c.type IN (" + strings.Join(placeholders, ",") + ")") - } - if f.Folder != "" { - b.Where("c.folder = ?", f.Folder) - } - if f.FolderID != "" { - b.Where("c.folder_id = ?", f.FolderID) - } - if f.Search != "" { - b.Where("c.title ILIKE ?", "%"+f.Search+"%") - } - if f.ProjectID == "none" { - b.WhereRaw("c.project_id IS NULL") - } else if f.ProjectID != "" { - b.Where("c.project_id = ?", f.ProjectID) - } - - // Count - countQ, countArgs := b.CountBuild() - var total int - DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total) - - b.OrderBy("c.is_pinned DESC, c.updated_at", "DESC") - b.Paginate(f.ListOptions) - - q, args := b.Build() - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - items, err := scanChannelListItems(rows) - if err != nil { - return nil, 0, err - } - - // Compute unread counts - for i := range items { - DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM messages m - JOIN channel_participants cp ON cp.channel_id = m.channel_id - WHERE cp.channel_id = $1 - AND cp.participant_type = 'user' AND cp.participant_id = $2 - AND m.created_at > cp.last_read_at - AND m.deleted_at IS NULL - `, items[i].ID, userID).Scan(&items[i].UnreadCount) - } - - return items, total, nil -} - -func (s *ChannelStore) GetForUser(ctx context.Context, channelID, userID string) (*store.ChannelListItem, error) { - row := DB.QueryRowContext(ctx, fmt.Sprintf(` - SELECT %s FROM %s - WHERE c.id = $1 AND (c.user_id = $2 OR c.id IN ( - SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $2 - )) - `, channelListCols, channelListFrom), channelID, userID) - - var item store.ChannelListItem - var tags []byte - var settings []byte - err := row.Scan( - &item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic, - &item.Description, &item.Model, &item.ProviderConfigID, - &item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID, - &tags, &settings, - &item.MessageCount, &item.CreatedAtTime, &item.UpdatedAtTime, - ) - if err != nil { - return nil, err - } - item.Tags = scanTagsBytes(tags) - item.Settings = safeJSONBytes(settings) - item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z") - item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z") - return &item, nil -} - -func (s *ChannelStore) CreateFull(ctx context.Context, ch *models.Channel, folder string, tags []string, aiMode string, - ownerUserID string, dmPartnerIDs []string, defaultModel, defaultConfigID string) error { - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - if tags == nil { - tags = []string{} - } - - // Insert channel - err = tx.QueryRowContext(ctx, ` - INSERT INTO channels (user_id, title, type, description, model, system_prompt, - provider_config_id, folder, folder_id, tags, ai_mode) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING id, created_at, updated_at`, - ch.UserID, ch.Title, ch.Type, ch.Description, ch.Model, ch.SystemPrompt, - models.NullString(ch.ProviderConfigID), folder, models.NullString(ch.FolderID), - pq.Array(tags), aiMode, - ).Scan(&ch.ID, &ch.CreatedAt, &ch.UpdatedAt) - if err != nil { - return fmt.Errorf("CreateFull insert channel: %w", err) - } - - // Add owner participant - _, err = tx.ExecContext(ctx, ` - INSERT INTO channel_participants (channel_id, participant_type, participant_id, role) - VALUES ($1, 'user', $2, 'owner') - ON CONFLICT DO NOTHING`, ch.ID, ownerUserID) - if err != nil { - return fmt.Errorf("CreateFull add owner: %w", err) - } - - // Add DM partner participants - for _, pid := range dmPartnerIDs { - if pid == ownerUserID { - continue - } - _, _ = tx.ExecContext(ctx, ` - INSERT INTO channel_participants (channel_id, participant_type, participant_id, role) - VALUES ($1, 'user', $2, 'member') - ON CONFLICT DO NOTHING`, ch.ID, pid) - } - - // Auto-create channel_model if model specified - if defaultModel != "" { - _, _ = tx.ExecContext(ctx, ` - INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default) - VALUES ($1, $2, $3, true) - ON CONFLICT DO NOTHING`, ch.ID, defaultModel, models.NullString(&defaultConfigID)) - } - - return tx.Commit() -} - -func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error { - _, err := DB.ExecContext(ctx, - `UPDATE channels SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb WHERE id = $2`, - []byte(settingsJSON), channelID) - return err -} - -// ── Monitoring (v0.35.0) ──────────────────── - -func (s *ChannelStore) ListByType(ctx context.Context, channelType string) ([]models.Channel, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, title, COALESCE(description, ''), type, - team_id, created_at - FROM channels WHERE type = $1 - ORDER BY created_at DESC`, channelType) - if err != nil { - return nil, err - } - defer rows.Close() - var result []models.Channel - for rows.Next() { - var ch models.Channel - if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description, - &ch.Type, &ch.TeamID, &ch.CreatedAt); err != nil { - return nil, err - } - result = append(result, ch) - } - return result, rows.Err() -} - -// ── CS7b helpers ──────────────────────────── - -func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) { - var result []store.ChannelListItem - for rows.Next() { - var item store.ChannelListItem - var tags []byte - var settings []byte - err := rows.Scan( - &item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic, - &item.Description, &item.Model, &item.ProviderConfigID, - &item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID, - &tags, &settings, - &item.MessageCount, &item.CreatedAtTime, &item.UpdatedAtTime, - ) - if err != nil { - return nil, err - } - item.Tags = scanTagsBytes(tags) - item.Settings = safeJSONBytes(settings) - item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z") - item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z") - result = append(result, item) - } - return result, rows.Err() -} - -func scanTagsBytes(b []byte) []string { - if len(b) == 0 { - return []string{} - } - // Try JSON array first (SQLite path), then PG text[] format - var arr []string - if json.Unmarshal(b, &arr) == nil { - if arr == nil { - return []string{} - } - return arr - } - // PG text[] format: {tag1,tag2} - s := strings.TrimPrefix(strings.TrimSuffix(string(b), "}"), "{") - if s == "" { - return []string{} - } - return strings.Split(s, ",") -} - -func safeJSONBytes(b []byte) json.RawMessage { - if len(b) == 0 || !json.Valid(b) { - return json.RawMessage("{}") - } - cp := make([]byte, len(b)) - copy(cp, b) - return json.RawMessage(cp) -} diff --git a/server/store/postgres/channel_helpers.go b/server/store/postgres/channel_helpers.go deleted file mode 100644 index 214a6ab..0000000 --- a/server/store/postgres/channel_helpers.go +++ /dev/null @@ -1,146 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" -) - -// ── Single-field helpers (v0.29.0) ────────────────────────────────────── -// Moved from handlers/completion.go and handlers/messages.go raw SQL. - -func (s *ChannelStore) GetAIMode(ctx context.Context, channelID string) (string, error) { - var aiMode string - err := DB.QueryRowContext(ctx, ` - SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1 - `, channelID).Scan(&aiMode) - if err != nil { - return "auto", err - } - return aiMode, nil -} - -func (s *ChannelStore) GetTypeAndTeamID(ctx context.Context, channelID string) (string, *string, error) { - var channelType string - var teamID *string - err := DB.QueryRowContext(ctx, ` - SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1 - `, channelID).Scan(&channelType, &teamID) - return channelType, teamID, err -} - -func (s *ChannelStore) GetSystemPrompt(ctx context.Context, channelID string) (*string, error) { - var prompt *string - err := DB.QueryRowContext(ctx, ` - SELECT system_prompt FROM channels WHERE id = $1 - `, channelID).Scan(&prompt) - if err == sql.ErrNoRows { - return nil, nil - } - return prompt, err -} - -func (s *ChannelStore) GetDefaultModel(ctx context.Context, channelID string) (*string, error) { - var model *string - err := DB.QueryRowContext(ctx, ` - SELECT model FROM channels WHERE id = $1 - `, channelID).Scan(&model) - if err == sql.ErrNoRows { - return nil, nil - } - return model, err -} - -func (s *ChannelStore) TouchUpdatedAt(ctx context.Context, channelID string) error { - _, err := DB.ExecContext(ctx, `UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID) - return err -} - -func (s *ChannelStore) ListUserParticipantIDs(ctx context.Context, channelID, excludeUserID string) ([]string, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT participant_id FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2 - `, channelID, excludeUserID) - 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 = []string{} - } - return ids, rows.Err() -} - -func (s *ChannelStore) ListPersonaParticipantIDs(ctx context.Context, channelID string) ([]string, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT participant_id FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'persona' - ORDER BY created_at - `, channelID) - 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 = []string{} - } - return ids, rows.Err() -} - -func (s *ChannelStore) GetLeaderPersonaID(ctx context.Context, channelID string) (string, error) { - // Try group leader first - var leaderID string - err := DB.QueryRowContext(ctx, ` - SELECT cp.participant_id - FROM channel_participants cp - JOIN persona_group_members pgm ON pgm.persona_id = cp.participant_id - WHERE cp.channel_id = $1 - AND cp.participant_type = 'persona' - AND pgm.is_leader = true - LIMIT 1 - `, channelID).Scan(&leaderID) - if err == nil && leaderID != "" { - return leaderID, nil - } - - // Fallback: first persona participant - err = DB.QueryRowContext(ctx, ` - SELECT participant_id FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'persona' - ORDER BY created_at LIMIT 1 - `, channelID).Scan(&leaderID) - if err == sql.ErrNoRows { - return "", nil - } - return leaderID, err -} - -func (s *ChannelStore) GetWorkflowInfo(ctx context.Context, channelID string) (*string, int, error) { - var workflowID *string - var currentStage int - err := DB.QueryRowContext(ctx, ` - SELECT workflow_id, COALESCE(current_stage, 0) - FROM channels WHERE id = $1 AND type = 'workflow' - `, channelID).Scan(&workflowID, ¤tStage) - if err == sql.ErrNoRows { - return nil, 0, nil - } - return workflowID, currentStage, err -} diff --git a/server/store/postgres/export.go b/server/store/postgres/export.go deleted file mode 100644 index a6525a4..0000000 --- a/server/store/postgres/export.go +++ /dev/null @@ -1,1433 +0,0 @@ -package postgres - -// export.go — v0.34.0 CS0 -// -// ExportStore: batch-read queries for data export and GDPR operations. -// Uses the shared package-level DB variable (set by SetDB in stores.go). - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/lib/pq" - - "switchboard-core/models" -) - -// ExportStore implements store.ExportStore for Postgres. -type ExportStore struct{} - -// NewExportStore creates a Postgres ExportStore. -func NewExportStore() *ExportStore { return &ExportStore{} } - -// ── helpers ────────────────────────────────── - -// placeholders builds "$1,$2,...,$n" and a matching []any slice for IN queries. -func placeholders(ids []string) (string, []any) { - ph := make([]string, len(ids)) - args := make([]any, len(ids)) - for i, id := range ids { - ph[i] = fmt.Sprintf("$%d", i+1) - args[i] = id - } - return strings.Join(ph, ","), args -} - -// ── User-scoped export ─────────────────────── - -func (s *ExportStore) UserChannels(ctx context.Context, userID string) ([]models.Channel, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, title, description, type, model, system_prompt, - provider_config_id, is_archived, is_pinned, folder_id, - team_id, project_id, workspace_id, settings, allow_anonymous, - created_at, updated_at - FROM channels WHERE user_id = $1 - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user channels: %w", err) - } - defer rows.Close() - var out []models.Channel - for rows.Next() { - var ch models.Channel - if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description, - &ch.Type, &ch.Model, &ch.SystemPrompt, &ch.ProviderConfigID, - &ch.IsArchived, &ch.IsPinned, &ch.FolderID, &ch.TeamID, - &ch.ProjectID, &ch.WorkspaceID, &ch.Settings, &ch.AllowAnonymous, - &ch.CreatedAt, &ch.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan channel: %w", err) - } - out = append(out, ch) - } - return out, rows.Err() -} - -func (s *ExportStore) UserMessages(ctx context.Context, channelIDs []string) ([]models.Message, error) { - if len(channelIDs) == 0 { - return nil, nil - } - ph, args := placeholders(channelIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, channel_id, role, content, model, tokens_used, - tool_calls, metadata, parent_id, sibling_index, - participant_type, participant_id, provider_config_id, - created_at, updated_at - FROM messages - WHERE channel_id IN (%s) AND deleted_at IS NULL - ORDER BY created_at ASC`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: user messages: %w", err) - } - defer rows.Close() - var out []models.Message - for rows.Next() { - var m models.Message - if err := rows.Scan(&m.ID, &m.ChannelID, &m.Role, &m.Content, - &m.Model, &m.TokensUsed, &m.ToolCalls, &m.Metadata, - &m.ParentID, &m.SiblingIndex, &m.ParticipantType, - &m.ParticipantID, &m.ProviderConfigID, - &m.CreatedAt, &m.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan message: %w", err) - } - out = append(out, m) - } - return out, rows.Err() -} - -func (s *ExportStore) UserChannelParticipants(ctx context.Context, channelIDs []string) ([]models.ChannelParticipant, error) { - if len(channelIDs) == 0 { - return nil, nil - } - ph, args := placeholders(channelIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, channel_id, participant_type, participant_id, role, - display_name, avatar_url, joined_at, last_read_at - FROM channel_participants WHERE channel_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: channel participants: %w", err) - } - defer rows.Close() - var out []models.ChannelParticipant - for rows.Next() { - var cp models.ChannelParticipant - if err := rows.Scan(&cp.ID, &cp.ChannelID, &cp.ParticipantType, - &cp.ParticipantID, &cp.Role, &cp.DisplayName, &cp.AvatarURL, - &cp.JoinedAt, &cp.LastReadAt); err != nil { - return nil, fmt.Errorf("export: scan participant: %w", err) - } - out = append(out, cp) - } - return out, rows.Err() -} - -func (s *ExportStore) UserChannelModels(ctx context.Context, channelIDs []string) ([]models.ChannelModel, error) { - if len(channelIDs) == 0 { - return nil, nil - } - ph, args := placeholders(channelIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, channel_id, model_id, provider_config_id, persona_id, - handle, display_name, system_prompt, is_default - FROM channel_models WHERE channel_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: channel models: %w", err) - } - defer rows.Close() - var out []models.ChannelModel - for rows.Next() { - var cm models.ChannelModel - if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, - &cm.ProviderConfigID, &cm.PersonaID, &cm.Handle, - &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil { - return nil, fmt.Errorf("export: scan channel model: %w", err) - } - out = append(out, cm) - } - return out, rows.Err() -} - -func (s *ExportStore) UserChannelCursors(ctx context.Context, userID string, channelIDs []string) ([]models.ChannelCursor, error) { - if len(channelIDs) == 0 { - return nil, nil - } - ph, args := placeholders(channelIDs) - args = append(args, userID) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, channel_id, user_id, active_leaf_id, updated_at - FROM channel_cursors - WHERE channel_id IN (%s) AND user_id = $%d`, ph, len(args)), args...) - if err != nil { - return nil, fmt.Errorf("export: channel cursors: %w", err) - } - defer rows.Close() - var out []models.ChannelCursor - for rows.Next() { - var cc models.ChannelCursor - if err := rows.Scan(&cc.ID, &cc.ChannelID, &cc.UserID, - &cc.ActiveLeafID, &cc.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan cursor: %w", err) - } - out = append(out, cc) - } - return out, rows.Err() -} - -func (s *ExportStore) UserNotes(ctx context.Context, userID string) ([]models.Note, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, title, content, folder_path, tags, - metadata, source_channel_id, source_message_id, team_id, - created_at, updated_at - FROM notes WHERE user_id = $1 - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user notes: %w", err) - } - defer rows.Close() - var out []models.Note - for rows.Next() { - var n models.Note - if err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, - &n.FolderPath, pq.Array(&n.Tags), &n.Metadata, - &n.SourceChannelID, &n.SourceMessageID, &n.TeamID, - &n.CreatedAt, &n.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan note: %w", err) - } - out = append(out, n) - } - return out, rows.Err() -} - -func (s *ExportStore) UserNoteLinks(ctx context.Context, noteIDs []string) ([]models.ExportNoteLink, error) { - if len(noteIDs) == 0 { - return nil, nil - } - ph, args := placeholders(noteIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT source_note_id, target_note_id, target_title, - COALESCE(display_text, ''), is_transclusion - FROM note_links WHERE source_note_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: note links: %w", err) - } - defer rows.Close() - var out []models.ExportNoteLink - for rows.Next() { - var nl models.ExportNoteLink - if err := rows.Scan(&nl.SourceNoteID, &nl.TargetNoteID, - &nl.TargetTitle, &nl.DisplayText, &nl.IsTransclusion); err != nil { - return nil, fmt.Errorf("export: scan note link: %w", err) - } - out = append(out, nl) - } - return out, rows.Err() -} - -func (s *ExportStore) UserMemories(ctx context.Context, userID string) ([]models.Memory, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories - WHERE owner_id = $1 AND scope = 'user' AND status = 'active' - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user memories: %w", err) - } - defer rows.Close() - var out []models.Memory - for rows.Next() { - var m models.Memory - if err := rows.Scan(&m.ID, &m.Scope, &m.OwnerID, &m.UserID, - &m.Key, &m.Value, &m.SourceChannelID, &m.Confidence, - &m.Status, &m.CreatedAt, &m.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan memory: %w", err) - } - out = append(out, m) - } - return out, rows.Err() -} - -func (s *ExportStore) UserProjects(ctx context.Context, userID string) ([]models.Project, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, description, color, icon, scope, owner_id, - team_id, is_archived, workspace_id, settings, created_at, updated_at - FROM projects WHERE owner_id = $1 AND scope = 'personal' - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user projects: %w", err) - } - defer rows.Close() - var out []models.Project - for rows.Next() { - var p models.Project - if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Color, - &p.Icon, &p.Scope, &p.OwnerID, &p.TeamID, &p.IsArchived, - &p.WorkspaceID, &p.Settings, &p.CreatedAt, &p.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan project: %w", err) - } - out = append(out, p) - } - return out, rows.Err() -} - -func (s *ExportStore) UserProjectChannels(ctx context.Context, projectIDs []string) ([]models.ProjectChannel, error) { - if len(projectIDs) == 0 { - return nil, nil - } - ph, args := placeholders(projectIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT project_id, channel_id, position, folder, added_at - FROM project_channels WHERE project_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: project channels: %w", err) - } - defer rows.Close() - var out []models.ProjectChannel - for rows.Next() { - var pc models.ProjectChannel - if err := rows.Scan(&pc.ProjectID, &pc.ChannelID, &pc.Position, - &pc.Folder, &pc.AddedAt); err != nil { - return nil, fmt.Errorf("export: scan project channel: %w", err) - } - out = append(out, pc) - } - return out, rows.Err() -} - -func (s *ExportStore) UserProjectKBs(ctx context.Context, projectIDs []string) ([]models.ProjectKB, error) { - if len(projectIDs) == 0 { - return nil, nil - } - ph, args := placeholders(projectIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT project_id, kb_id, auto_search, added_at - FROM project_knowledge_bases WHERE project_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: project KBs: %w", err) - } - defer rows.Close() - var out []models.ProjectKB - for rows.Next() { - var pk models.ProjectKB - if err := rows.Scan(&pk.ProjectID, &pk.KBID, &pk.AutoSearch, &pk.AddedAt); err != nil { - return nil, fmt.Errorf("export: scan project KB: %w", err) - } - out = append(out, pk) - } - return out, rows.Err() -} - -func (s *ExportStore) UserProjectNotes(ctx context.Context, projectIDs []string) ([]models.ProjectNote, error) { - if len(projectIDs) == 0 { - return nil, nil - } - ph, args := placeholders(projectIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT project_id, note_id, added_at - FROM project_notes WHERE project_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: project notes: %w", err) - } - defer rows.Close() - var out []models.ProjectNote - for rows.Next() { - var pn models.ProjectNote - if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt); err != nil { - return nil, fmt.Errorf("export: scan project note: %w", err) - } - out = append(out, pn) - } - return out, rows.Err() -} - -func (s *ExportStore) UserWorkspaces(ctx context.Context, userID string) ([]models.Workspace, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, owner_type, owner_id, name, root_path, max_bytes, status, - indexing_enabled, git_remote_url, git_branch, git_credential_id, - git_last_sync, created_at, updated_at - FROM workspaces WHERE owner_type = 'user' AND owner_id = $1 - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user workspaces: %w", err) - } - defer rows.Close() - var out []models.Workspace - for rows.Next() { - var w models.Workspace - if err := rows.Scan(&w.ID, &w.OwnerType, &w.OwnerID, &w.Name, - &w.RootPath, &w.MaxBytes, &w.Status, &w.IndexingEnabled, - &w.GitRemoteURL, &w.GitBranch, &w.GitCredentialID, - &w.GitLastSync, &w.CreatedAt, &w.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan workspace: %w", err) - } - out = append(out, w) - } - return out, rows.Err() -} - -func (s *ExportStore) UserWorkspaceFiles(ctx context.Context, workspaceIDs []string) ([]models.WorkspaceFile, error) { - if len(workspaceIDs) == 0 { - return nil, nil - } - ph, args := placeholders(workspaceIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, workspace_id, path, is_directory, content_type, - size_bytes, sha256, index_status, chunk_count, - created_at, updated_at - FROM workspace_files WHERE workspace_id IN (%s) - ORDER BY path ASC`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: workspace files: %w", err) - } - defer rows.Close() - var out []models.WorkspaceFile - for rows.Next() { - var wf models.WorkspaceFile - if err := rows.Scan(&wf.ID, &wf.WorkspaceID, &wf.Path, - &wf.IsDirectory, &wf.ContentType, &wf.SizeBytes, - &wf.SHA256, &wf.IndexStatus, &wf.ChunkCount, - &wf.CreatedAt, &wf.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan workspace file: %w", err) - } - out = append(out, wf) - } - return out, rows.Err() -} - -func (s *ExportStore) UserFiles(ctx context.Context, userID string) ([]models.File, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, channel_id, message_id, user_id, project_id, - origin, filename, content_type, size_bytes, storage_key, - display_hint, extracted_text, metadata, created_at, updated_at - FROM files WHERE user_id = $1 - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user files: %w", err) - } - defer rows.Close() - var out []models.File - for rows.Next() { - var f models.File - if err := rows.Scan(&f.ID, &f.ChannelID, &f.MessageID, &f.UserID, - &f.ProjectID, &f.Origin, &f.Filename, &f.ContentType, - &f.SizeBytes, &f.StorageKey, &f.DisplayHint, - &f.ExtractedText, &f.Metadata, &f.CreatedAt, &f.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan file: %w", err) - } - out = append(out, f) - } - return out, rows.Err() -} - -func (s *ExportStore) UserFolders(ctx context.Context, userID string) ([]models.Folder, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, name, parent_id, sort_order, created_at, updated_at - FROM folders WHERE user_id = $1 - ORDER BY sort_order ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user folders: %w", err) - } - defer rows.Close() - var out []models.Folder - for rows.Next() { - var f models.Folder - if err := rows.Scan(&f.ID, &f.UserID, &f.Name, &f.ParentID, - &f.SortOrder, &f.CreatedAt, &f.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan folder: %w", err) - } - out = append(out, f) - } - return out, rows.Err() -} - -func (s *ExportStore) UserModelSettings(ctx context.Context, userID string) ([]models.UserModelSetting, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, model_id, provider_config_id, hidden, - preferred_temperature, preferred_max_tokens, sort_order, - created_at, updated_at - FROM user_model_settings WHERE user_id = $1`, userID) - if err != nil { - return nil, fmt.Errorf("export: user model settings: %w", err) - } - defer rows.Close() - var out []models.UserModelSetting - for rows.Next() { - var s models.UserModelSetting - if err := rows.Scan(&s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID, - &s.Hidden, &s.PreferredTemperature, &s.PreferredMaxTokens, - &s.SortOrder, &s.CreatedAt, &s.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan user model setting: %w", err) - } - out = append(out, s) - } - return out, rows.Err() -} - -func (s *ExportStore) UserNotifPrefs(ctx context.Context, userID string) ([]models.NotificationPreference, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, type, in_app, email - FROM notification_preferences WHERE user_id = $1`, userID) - if err != nil { - return nil, fmt.Errorf("export: notification prefs: %w", err) - } - defer rows.Close() - var out []models.NotificationPreference - for rows.Next() { - var np models.NotificationPreference - if err := rows.Scan(&np.ID, &np.UserID, &np.Type, &np.InApp, &np.Email); err != nil { - return nil, fmt.Errorf("export: scan notif pref: %w", err) - } - out = append(out, np) - } - return out, rows.Err() -} - -func (s *ExportStore) UserUsageEntries(ctx context.Context, userID string) ([]models.UsageEntry, error) { - cutoff := time.Now().AddDate(0, 0, -90) - rows, err := DB.QueryContext(ctx, ` - SELECT 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, routing_decision, created_at - FROM usage_log - WHERE user_id = $1 AND created_at >= $2 - ORDER BY created_at ASC`, userID, cutoff) - if err != nil { - return nil, fmt.Errorf("export: usage entries: %w", err) - } - defer rows.Close() - var out []models.UsageEntry - for rows.Next() { - var ue models.UsageEntry - if err := rows.Scan(&ue.ID, &ue.ChannelID, &ue.UserID, - &ue.ProviderConfigID, &ue.ProviderScope, &ue.ModelID, - &ue.Role, &ue.PromptTokens, &ue.CompletionTokens, - &ue.CacheCreationTokens, &ue.CacheReadTokens, - &ue.CostInput, &ue.CostOutput, &ue.RoutingDecision, - &ue.CreatedAt); err != nil { - return nil, fmt.Errorf("export: scan usage entry: %w", err) - } - out = append(out, ue) - } - return out, rows.Err() -} - -func (s *ExportStore) UserPersonaGroups(ctx context.Context, userID string) ([]models.PersonaGroup, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, description, owner_id, scope, team_id, - created_at, updated_at - FROM persona_groups WHERE owner_id = $1 - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: persona groups: %w", err) - } - defer rows.Close() - var out []models.PersonaGroup - for rows.Next() { - var pg models.PersonaGroup - if err := rows.Scan(&pg.ID, &pg.Name, &pg.Description, &pg.OwnerID, - &pg.Scope, &pg.TeamID, &pg.CreatedAt, &pg.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan persona group: %w", err) - } - out = append(out, pg) - } - return out, rows.Err() -} - -func (s *ExportStore) UserPersonaGroupMembers(ctx context.Context, groupIDs []string) ([]models.PersonaGroupMember, error) { - if len(groupIDs) == 0 { - return nil, nil - } - ph, args := placeholders(groupIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, group_id, persona_id, is_leader, sort_order - FROM persona_group_members WHERE group_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: persona group members: %w", err) - } - defer rows.Close() - var out []models.PersonaGroupMember - for rows.Next() { - var pgm models.PersonaGroupMember - if err := rows.Scan(&pgm.ID, &pgm.GroupID, &pgm.PersonaID, - &pgm.IsLeader, &pgm.SortOrder); err != nil { - return nil, fmt.Errorf("export: scan persona group member: %w", err) - } - out = append(out, pgm) - } - return out, rows.Err() -} - -// ── Team-scoped export ─────────────────────── - -func (s *ExportStore) TeamChannels(ctx context.Context, teamID string) ([]models.Channel, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, title, description, type, model, system_prompt, - provider_config_id, is_archived, is_pinned, folder_id, - team_id, project_id, workspace_id, settings, allow_anonymous, - created_at, updated_at - FROM channels WHERE team_id = $1 - ORDER BY created_at ASC`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team channels: %w", err) - } - defer rows.Close() - var out []models.Channel - for rows.Next() { - var ch models.Channel - if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description, - &ch.Type, &ch.Model, &ch.SystemPrompt, &ch.ProviderConfigID, - &ch.IsArchived, &ch.IsPinned, &ch.FolderID, &ch.TeamID, - &ch.ProjectID, &ch.WorkspaceID, &ch.Settings, &ch.AllowAnonymous, - &ch.CreatedAt, &ch.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan team channel: %w", err) - } - out = append(out, ch) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamMembers(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, 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 = $1`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team members: %w", err) - } - defer rows.Close() - var out []models.TeamMember - for rows.Next() { - var tm models.TeamMember - if err := rows.Scan(&tm.ID, &tm.TeamID, &tm.UserID, &tm.Role, - &tm.JoinedAt, &tm.Email, &tm.DisplayName, &tm.Username, - &tm.UserRole); err != nil { - return nil, fmt.Errorf("export: scan team member: %w", err) - } - out = append(out, tm) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamPersonas(ctx context.Context, teamID string) ([]models.Persona, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, handle, 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, - memory_enabled, memory_extraction_prompt, - created_at, updated_at - FROM personas WHERE scope = 'team' AND owner_id = $1 - ORDER BY created_at ASC`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team personas: %w", err) - } - defer rows.Close() - var out []models.Persona - for rows.Next() { - var p models.Persona - if err := rows.Scan(&p.ID, &p.Name, &p.Handle, &p.Description, - &p.Icon, &p.Avatar, &p.BaseModelID, &p.ProviderConfigID, - &p.SystemPrompt, &p.Temperature, &p.MaxTokens, - &p.ThinkingBudget, &p.TopP, &p.Scope, &p.OwnerID, - &p.CreatedBy, &p.IsActive, &p.IsShared, - &p.MemoryEnabled, &p.MemoryExtractionPrompt, - &p.CreatedAt, &p.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan persona: %w", err) - } - out = append(out, p) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamPersonaKBs(ctx context.Context, personaIDs []string) ([]models.PersonaKB, error) { - if len(personaIDs) == 0 { - return nil, nil - } - ph, args := placeholders(personaIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT persona_id, kb_id, auto_search, added_at - FROM persona_knowledge_bases WHERE persona_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: persona KBs: %w", err) - } - defer rows.Close() - var out []models.PersonaKB - for rows.Next() { - var pk models.PersonaKB - if err := rows.Scan(&pk.PersonaID, &pk.KBID, &pk.AutoSearch, &pk.AddedAt); err != nil { - return nil, fmt.Errorf("export: scan persona KB: %w", err) - } - out = append(out, pk) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamKnowledgeBases(ctx context.Context, teamID string) ([]models.KnowledgeBase, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, description, scope, owner_id, team_id, - embedding_config, document_count, chunk_count, total_bytes, - discoverable, status, created_at, updated_at - FROM knowledge_bases WHERE team_id = $1 - ORDER BY created_at ASC`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team KBs: %w", err) - } - defer rows.Close() - var out []models.KnowledgeBase - for rows.Next() { - var kb models.KnowledgeBase - if err := rows.Scan(&kb.ID, &kb.Name, &kb.Description, &kb.Scope, - &kb.OwnerID, &kb.TeamID, &kb.EmbeddingConfig, &kb.DocumentCount, - &kb.ChunkCount, &kb.TotalBytes, &kb.Discoverable, &kb.Status, - &kb.CreatedAt, &kb.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan KB: %w", err) - } - out = append(out, kb) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamKBDocuments(ctx context.Context, kbIDs []string) ([]models.KBDocument, error) { - if len(kbIDs) == 0 { - return nil, nil - } - ph, args := placeholders(kbIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - 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 IN (%s) - ORDER BY created_at ASC`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: KB documents: %w", err) - } - defer rows.Close() - var out []models.KBDocument - for rows.Next() { - var d models.KBDocument - if err := rows.Scan(&d.ID, &d.KBID, &d.Filename, &d.ContentType, - &d.SizeBytes, &d.StorageKey, &d.ChunkCount, &d.Status, - &d.Error, &d.UploadedBy, &d.CreatedAt, &d.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan KB document: %w", err) - } - out = append(out, d) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamWorkflows(ctx context.Context, teamID string) ([]models.Workflow, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, team_id, name, slug, description, branding, - entry_mode, is_active, version, on_complete, retention, - webhook_url, webhook_secret, created_by, created_at, updated_at - FROM workflows WHERE team_id = $1 - ORDER BY created_at ASC`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team workflows: %w", err) - } - defer rows.Close() - var out []models.Workflow - for rows.Next() { - var w models.Workflow - if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, - &w.Description, &w.Branding, &w.EntryMode, &w.IsActive, - &w.Version, &w.OnComplete, &w.Retention, &w.WebhookURL, - &w.WebhookSecret, &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan workflow: %w", err) - } - out = append(out, w) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamWorkflowVersions(ctx context.Context, workflowIDs []string) ([]models.WorkflowVersion, error) { - if len(workflowIDs) == 0 { - return nil, nil - } - ph, args := placeholders(workflowIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, workflow_id, version_number, snapshot, created_at - FROM workflow_versions WHERE workflow_id IN (%s) - ORDER BY version_number ASC`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: workflow versions: %w", err) - } - defer rows.Close() - var out []models.WorkflowVersion - for rows.Next() { - var wv models.WorkflowVersion - if err := rows.Scan(&wv.ID, &wv.WorkflowID, &wv.VersionNumber, - &wv.Snapshot, &wv.CreatedAt); err != nil { - return nil, fmt.Errorf("export: scan workflow version: %w", err) - } - out = append(out, wv) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamWorkflowStages(ctx context.Context, workflowIDs []string) ([]models.WorkflowStage, error) { - if len(workflowIDs) == 0 { - return nil, nil - } - ph, args := placeholders(workflowIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, workflow_id, ordinal, name, persona_id, - assignment_team_id, form_template, stage_mode, - history_mode, auto_transition, transition_rules, - surface_pkg_id, created_at - FROM workflow_stages WHERE workflow_id IN (%s) - ORDER BY ordinal ASC`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: workflow stages: %w", err) - } - defer rows.Close() - var out []models.WorkflowStage - for rows.Next() { - var ws models.WorkflowStage - if err := rows.Scan(&ws.ID, &ws.WorkflowID, &ws.Ordinal, &ws.Name, - &ws.PersonaID, &ws.AssignmentTeamID, &ws.FormTemplate, - &ws.StageMode, &ws.HistoryMode, &ws.AutoTransition, - &ws.TransitionRules, &ws.SurfacePkgID, &ws.CreatedAt); err != nil { - return nil, fmt.Errorf("export: scan workflow stage: %w", err) - } - out = append(out, ws) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamGroups(ctx context.Context, teamID string) ([]models.Group, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, description, scope, team_id, created_by, source, - permissions, token_budget_daily, token_budget_monthly, - allowed_models, created_at, updated_at - FROM groups WHERE team_id = $1 - ORDER BY created_at ASC`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team groups: %w", err) - } - defer rows.Close() - var out []models.Group - for rows.Next() { - var g models.Group - if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, - &g.TeamID, &g.CreatedBy, &g.Source, - pq.Array(&g.Permissions), &g.TokenBudgetDaily, - &g.TokenBudgetMonthly, pq.Array(&g.AllowedModels), - &g.CreatedAt, &g.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan group: %w", err) - } - out = append(out, g) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamGroupMembers(ctx context.Context, groupIDs []string) ([]models.GroupMember, error) { - if len(groupIDs) == 0 { - return nil, nil - } - ph, args := placeholders(groupIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, group_id, user_id, added_by, added_at - FROM group_members WHERE group_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: group members: %w", err) - } - defer rows.Close() - var out []models.GroupMember - for rows.Next() { - var gm models.GroupMember - if err := rows.Scan(&gm.ID, &gm.GroupID, &gm.UserID, &gm.AddedBy, - &gm.AddedAt); err != nil { - return nil, fmt.Errorf("export: scan group member: %w", err) - } - out = append(out, gm) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamResourceGrants(ctx context.Context, teamID string) ([]models.ResourceGrant, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT rg.id, rg.resource_type, rg.resource_id, rg.grant_scope, - rg.granted_groups, rg.created_by, rg.created_at, rg.updated_at - FROM resource_grants rg - WHERE rg.resource_id IN ( - SELECT id FROM personas WHERE scope = 'team' AND owner_id = $1 - UNION ALL - SELECT id FROM knowledge_bases WHERE team_id = $1 - )`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team resource grants: %w", err) - } - defer rows.Close() - var out []models.ResourceGrant - for rows.Next() { - var rg models.ResourceGrant - if err := rows.Scan(&rg.ID, &rg.ResourceType, &rg.ResourceID, - &rg.GrantScope, pq.Array(&rg.GrantedGroups), - &rg.CreatedBy, &rg.CreatedAt, &rg.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan resource grant: %w", err) - } - out = append(out, rg) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamProjects(ctx context.Context, teamID string) ([]models.Project, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, description, color, icon, scope, owner_id, - team_id, is_archived, workspace_id, settings, created_at, updated_at - FROM projects WHERE team_id = $1 - ORDER BY created_at ASC`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team projects: %w", err) - } - defer rows.Close() - var out []models.Project - for rows.Next() { - var p models.Project - if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Color, - &p.Icon, &p.Scope, &p.OwnerID, &p.TeamID, &p.IsArchived, - &p.WorkspaceID, &p.Settings, &p.CreatedAt, &p.UpdatedAt); err != nil { - return nil, fmt.Errorf("export: scan project: %w", err) - } - out = append(out, p) - } - return out, rows.Err() -} - -// ── GDPR ───────────────────────────────────── - -func (s *ExportStore) CountActiveAdmins(ctx context.Context) (int, error) { - var count int - err := DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND is_active = true`).Scan(&count) - return count, err -} - -func (s *ExportStore) AnonymizeUser(ctx context.Context, userID string, anonHash string) error { - _, err := DB.ExecContext(ctx, ` - UPDATE users SET - username = $2, - email = $3, - display_name = 'Deleted User', - avatar_url = '', - handle = $4, - settings = NULL, - external_id = NULL, - is_active = false, - password_hash = '', - updated_at = NOW() - WHERE id = $1`, - userID, - "deleted-user-"+anonHash, - "deleted-"+anonHash+"@deleted.local", - "deleted-"+anonHash, - ) - return err -} - -func (s *ExportStore) SoftDeleteUserData(ctx context.Context, userID string) (map[string]int64, error) { - counts := make(map[string]int64) - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return nil, fmt.Errorf("export: begin tx: %w", err) - } - defer tx.Rollback() - - now := time.Now() - - // Messages in user-owned channels - r, err := tx.ExecContext(ctx, ` - UPDATE messages SET deleted_at = $1 - WHERE channel_id IN (SELECT id FROM channels WHERE user_id = $2) - AND deleted_at IS NULL`, now, userID) - if err != nil { - return nil, fmt.Errorf("export: soft-delete owned messages: %w", err) - } - n, _ := r.RowsAffected() - counts["messages_owned"] = n - - // Messages sent by user in other channels - r, err = tx.ExecContext(ctx, ` - UPDATE messages SET deleted_at = $1 - WHERE participant_type = 'user' AND participant_id = $2 - AND deleted_at IS NULL`, now, userID) - if err != nil { - return nil, fmt.Errorf("export: soft-delete sent messages: %w", err) - } - n, _ = r.RowsAffected() - counts["messages_sent"] = n - - // Archive channels - r, err = tx.ExecContext(ctx, `UPDATE channels SET is_archived = true WHERE user_id = $1`, userID) - if err != nil { - return nil, fmt.Errorf("export: archive channels: %w", err) - } - n, _ = r.RowsAffected() - counts["channels"] = n - - // Notes + links - tx.ExecContext(ctx, `DELETE FROM note_links WHERE source_note_id IN (SELECT id FROM notes WHERE user_id = $1)`, userID) - r, err = tx.ExecContext(ctx, `DELETE FROM notes WHERE user_id = $1`, userID) - if err != nil { - return nil, fmt.Errorf("export: delete notes: %w", err) - } - n, _ = r.RowsAffected() - counts["notes"] = n - - // Memories - r, err = tx.ExecContext(ctx, `DELETE FROM memories WHERE owner_id = $1 AND scope = 'user'`, userID) - if err != nil { - return nil, fmt.Errorf("export: delete memories: %w", err) - } - n, _ = r.RowsAffected() - counts["memories"] = n - - // Workspace cascade - tx.ExecContext(ctx, `DELETE FROM workspace_chunks WHERE workspace_id IN (SELECT id FROM workspaces WHERE owner_type = 'user' AND owner_id = $1)`, userID) - tx.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id IN (SELECT id FROM workspaces WHERE owner_type = 'user' AND owner_id = $1)`, userID) - r, err = tx.ExecContext(ctx, `DELETE FROM workspaces WHERE owner_type = 'user' AND owner_id = $1`, userID) - if err != nil { - return nil, fmt.Errorf("export: delete workspaces: %w", err) - } - n, _ = r.RowsAffected() - counts["workspaces"] = n - - // Files - r, err = tx.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1`, userID) - if err != nil { - return nil, fmt.Errorf("export: delete files: %w", err) - } - n, _ = r.RowsAffected() - counts["files"] = n - - // Projects cascade - tx.ExecContext(ctx, `DELETE FROM project_notes WHERE project_id IN (SELECT id FROM projects WHERE scope = 'personal' AND owner_id = $1)`, userID) - tx.ExecContext(ctx, `DELETE FROM project_knowledge_bases WHERE project_id IN (SELECT id FROM projects WHERE scope = 'personal' AND owner_id = $1)`, userID) - tx.ExecContext(ctx, `DELETE FROM project_channels WHERE project_id IN (SELECT id FROM projects WHERE scope = 'personal' AND owner_id = $1)`, userID) - r, err = tx.ExecContext(ctx, `DELETE FROM projects WHERE scope = 'personal' AND owner_id = $1`, userID) - if err != nil { - return nil, fmt.Errorf("export: delete projects: %w", err) - } - n, _ = r.RowsAffected() - counts["projects"] = n - - // Folders, settings, prefs, notifications - tx.ExecContext(ctx, `DELETE FROM folders WHERE user_id = $1`, userID) - tx.ExecContext(ctx, `DELETE FROM user_model_settings WHERE user_id = $1`, userID) - tx.ExecContext(ctx, `DELETE FROM notification_preferences WHERE user_id = $1`, userID) - tx.ExecContext(ctx, `DELETE FROM notifications WHERE user_id = $1`, userID) - - // Persona groups - tx.ExecContext(ctx, `DELETE FROM persona_group_members WHERE group_id IN (SELECT id FROM persona_groups WHERE owner_id = $1)`, userID) - tx.ExecContext(ctx, `DELETE FROM persona_groups WHERE owner_id = $1`, userID) - - // Channel participation - tx.ExecContext(ctx, `DELETE FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1`, userID) - tx.ExecContext(ctx, `DELETE FROM channel_cursors WHERE user_id = $1`, userID) - - // Memory extraction log, presence - tx.ExecContext(ctx, `DELETE FROM memory_extraction_log WHERE user_id = $1`, userID) - tx.ExecContext(ctx, `DELETE FROM user_presence WHERE user_id = $1`, userID) - - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("export: commit: %w", err) - } - return counts, nil -} - -func (s *ExportStore) DeleteUserTokens(ctx context.Context, userID string) error { - _, err := DB.ExecContext(ctx, `DELETE FROM refresh_tokens WHERE user_id = $1`, userID) - if err != nil { - return fmt.Errorf("export: delete refresh tokens: %w", err) - } - _, err = DB.ExecContext(ctx, `DELETE FROM ws_tickets WHERE user_id = $1`, userID) - if err != nil { - return fmt.Errorf("export: delete ws tickets: %w", err) - } - return nil -} - -// ── Import (CS1) ───────────────────────────── - -func (s *ExportStore) ImportFolders(ctx context.Context, folders []models.Folder) (int, int, error) { - imported, skipped := 0, 0 - for _, f := range folders { - r, err := DB.ExecContext(ctx, ` - INSERT INTO folders (id, user_id, name, parent_id, sort_order, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (id) DO NOTHING`, - f.ID, f.UserID, f.Name, f.ParentID, f.SortOrder, f.CreatedAt, f.UpdatedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import folder %s: %w", f.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportProjects(ctx context.Context, projects []models.Project) (int, int, error) { - imported, skipped := 0, 0 - for _, p := range projects { - r, err := DB.ExecContext(ctx, ` - INSERT INTO projects (id, name, description, color, icon, scope, owner_id, - team_id, is_archived, workspace_id, settings, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) - ON CONFLICT (id) DO NOTHING`, - p.ID, p.Name, p.Description, p.Color, p.Icon, p.Scope, p.OwnerID, - p.TeamID, p.IsArchived, p.WorkspaceID, p.Settings, p.CreatedAt, p.UpdatedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import project %s: %w", p.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportWorkspaces(ctx context.Context, workspaces []models.Workspace) (int, int, error) { - imported, skipped := 0, 0 - for _, w := range workspaces { - r, err := DB.ExecContext(ctx, ` - INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status, - indexing_enabled, git_remote_url, git_branch, git_credential_id, - git_last_sync, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) - ON CONFLICT (id) DO NOTHING`, - w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath, w.MaxBytes, w.Status, - w.IndexingEnabled, w.GitRemoteURL, w.GitBranch, w.GitCredentialID, - w.GitLastSync, w.CreatedAt, w.UpdatedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import workspace %s: %w", w.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportChannels(ctx context.Context, channels []models.Channel) (int, int, error) { - imported, skipped := 0, 0 - for _, ch := range channels { - r, 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, project_id, workspace_id, settings, allow_anonymous, - created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) - ON CONFLICT (id) DO NOTHING`, - ch.ID, ch.UserID, ch.Title, ch.Description, ch.Type, ch.Model, ch.SystemPrompt, - ch.ProviderConfigID, ch.IsArchived, ch.IsPinned, ch.FolderID, ch.TeamID, - ch.ProjectID, ch.WorkspaceID, ch.Settings, ch.AllowAnonymous, - ch.CreatedAt, ch.UpdatedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import channel %s: %w", ch.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportChannelParticipants(ctx context.Context, cps []models.ChannelParticipant) (int, int, error) { - imported, skipped := 0, 0 - for _, cp := range cps { - r, err := DB.ExecContext(ctx, ` - INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role, - display_name, avatar_url, joined_at, last_read_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) - ON CONFLICT (id) DO NOTHING`, - cp.ID, cp.ChannelID, cp.ParticipantType, cp.ParticipantID, cp.Role, - cp.DisplayName, cp.AvatarURL, cp.JoinedAt, cp.LastReadAt) - if err != nil { - return imported, skipped, fmt.Errorf("import participant %s: %w", cp.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportChannelModels(ctx context.Context, cms []models.ChannelModel) (int, int, error) { - imported, skipped := 0, 0 - for _, cm := range cms { - r, err := DB.ExecContext(ctx, ` - INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, persona_id, - handle, display_name, system_prompt, is_default) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) - ON CONFLICT (id) DO NOTHING`, - cm.ID, cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.PersonaID, - cm.Handle, cm.DisplayName, cm.SystemPrompt, cm.IsDefault) - if err != nil { - return imported, skipped, fmt.Errorf("import channel model %s: %w", cm.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportChannelCursors(ctx context.Context, cursors []models.ChannelCursor) (int, int, error) { - imported, skipped := 0, 0 - for _, cc := range cursors { - r, err := DB.ExecContext(ctx, ` - INSERT INTO channel_cursors (id, channel_id, user_id, active_leaf_id, updated_at) - VALUES ($1,$2,$3,$4,$5) - ON CONFLICT (id) DO NOTHING`, - cc.ID, cc.ChannelID, cc.UserID, cc.ActiveLeafID, cc.UpdatedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import cursor %s: %w", cc.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportMessages(ctx context.Context, msgs []models.Message) (int, int, error) { - imported, skipped := 0, 0 - for _, m := range msgs { - r, 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, provider_config_id, - created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) - ON CONFLICT (id) DO NOTHING`, - m.ID, m.ChannelID, m.Role, m.Content, m.Model, m.TokensUsed, - m.ToolCalls, m.Metadata, m.ParentID, m.SiblingIndex, - m.ParticipantType, m.ParticipantID, m.ProviderConfigID, - m.CreatedAt, m.UpdatedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import message %s: %w", m.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportNotes(ctx context.Context, notes []models.Note) (int, int, error) { - imported, skipped := 0, 0 - for _, n := range notes { - r, err := DB.ExecContext(ctx, ` - INSERT INTO notes (id, user_id, title, content, folder_path, tags, - metadata, source_channel_id, source_message_id, team_id, - created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) - ON CONFLICT (id) DO NOTHING`, - n.ID, n.UserID, n.Title, n.Content, n.FolderPath, pq.Array(n.Tags), - n.Metadata, n.SourceChannelID, n.SourceMessageID, n.TeamID, - n.CreatedAt, n.UpdatedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import note %s: %w", n.ID, err) - } - nr, _ := r.RowsAffected() - if nr > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportNoteLinks(ctx context.Context, links []models.ExportNoteLink) (int, int, error) { - imported, skipped := 0, 0 - for _, nl := range links { - r, err := DB.ExecContext(ctx, ` - INSERT INTO note_links (source_note_id, target_note_id, target_title, display_text, is_transclusion) - VALUES ($1,$2,$3,$4,$5) - ON CONFLICT DO NOTHING`, - nl.SourceNoteID, nl.TargetNoteID, nl.TargetTitle, nl.DisplayText, nl.IsTransclusion) - if err != nil { - return imported, skipped, fmt.Errorf("import note link: %w", err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportMemories(ctx context.Context, mems []models.Memory) (int, int, error) { - imported, skipped := 0, 0 - for _, m := range mems { - r, err := DB.ExecContext(ctx, ` - INSERT INTO memories (id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) - ON CONFLICT (id) DO NOTHING`, - m.ID, m.Scope, m.OwnerID, m.UserID, m.Key, m.Value, - m.SourceChannelID, m.Confidence, m.Status, m.CreatedAt, m.UpdatedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import memory %s: %w", m.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportProjectChannels(ctx context.Context, pcs []models.ProjectChannel) (int, int, error) { - imported, skipped := 0, 0 - for _, pc := range pcs { - r, err := DB.ExecContext(ctx, ` - INSERT INTO project_channels (project_id, channel_id, position, folder, added_at) - VALUES ($1,$2,$3,$4,$5) - ON CONFLICT DO NOTHING`, - pc.ProjectID, pc.ChannelID, pc.Position, pc.Folder, pc.AddedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import project channel: %w", err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportProjectKBs(ctx context.Context, pks []models.ProjectKB) (int, int, error) { - imported, skipped := 0, 0 - for _, pk := range pks { - r, err := DB.ExecContext(ctx, ` - INSERT INTO project_knowledge_bases (project_id, kb_id, auto_search, added_at) - VALUES ($1,$2,$3,$4) - ON CONFLICT DO NOTHING`, - pk.ProjectID, pk.KBID, pk.AutoSearch, pk.AddedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import project KB: %w", err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportProjectNotes(ctx context.Context, pns []models.ProjectNote) (int, int, error) { - imported, skipped := 0, 0 - for _, pn := range pns { - r, err := DB.ExecContext(ctx, ` - INSERT INTO project_notes (project_id, note_id, added_at) - VALUES ($1,$2,$3) - ON CONFLICT DO NOTHING`, - pn.ProjectID, pn.NoteID, pn.AddedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import project note: %w", err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportWorkspaceFiles(ctx context.Context, wfs []models.WorkspaceFile) (int, int, error) { - imported, skipped := 0, 0 - for _, wf := range wfs { - r, err := DB.ExecContext(ctx, ` - INSERT INTO workspace_files (id, workspace_id, path, is_directory, content_type, - size_bytes, sha256, index_status, chunk_count, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) - ON CONFLICT (id) DO NOTHING`, - wf.ID, wf.WorkspaceID, wf.Path, wf.IsDirectory, wf.ContentType, - wf.SizeBytes, wf.SHA256, wf.IndexStatus, wf.ChunkCount, - wf.CreatedAt, wf.UpdatedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import workspace file %s: %w", wf.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportFiles(ctx context.Context, files []models.File) (int, int, error) { - imported, skipped := 0, 0 - for _, f := range files { - r, err := DB.ExecContext(ctx, ` - INSERT INTO files (id, channel_id, message_id, user_id, project_id, - origin, filename, content_type, size_bytes, storage_key, - display_hint, extracted_text, metadata, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) - ON CONFLICT (id) DO NOTHING`, - f.ID, f.ChannelID, f.MessageID, f.UserID, f.ProjectID, - f.Origin, f.Filename, f.ContentType, f.SizeBytes, f.StorageKey, - f.DisplayHint, f.ExtractedText, f.Metadata, f.CreatedAt, f.UpdatedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import file %s: %w", f.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportUserModelSettings(ctx context.Context, ums []models.UserModelSetting) (int, int, error) { - imported, skipped := 0, 0 - for _, um := range ums { - r, err := DB.ExecContext(ctx, ` - INSERT INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden, - preferred_temperature, preferred_max_tokens, sort_order, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) - ON CONFLICT (id) DO NOTHING`, - um.ID, um.UserID, um.ModelID, um.ProviderConfigID, um.Hidden, - um.PreferredTemperature, um.PreferredMaxTokens, um.SortOrder, - um.CreatedAt, um.UpdatedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import user model setting %s: %w", um.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportNotifPrefs(ctx context.Context, nps []models.NotificationPreference) (int, int, error) { - imported, skipped := 0, 0 - for _, np := range nps { - r, err := DB.ExecContext(ctx, ` - INSERT INTO notification_preferences (id, user_id, type, in_app, email) - VALUES ($1,$2,$3,$4,$5) - ON CONFLICT (id) DO NOTHING`, - np.ID, np.UserID, np.Type, np.InApp, np.Email) - if err != nil { - return imported, skipped, fmt.Errorf("import notif pref %s: %w", np.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportPersonaGroups(ctx context.Context, pgs []models.PersonaGroup) (int, int, error) { - imported, skipped := 0, 0 - for _, pg := range pgs { - r, err := DB.ExecContext(ctx, ` - INSERT INTO persona_groups (id, name, description, owner_id, scope, team_id, - created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8) - ON CONFLICT (id) DO NOTHING`, - pg.ID, pg.Name, pg.Description, pg.OwnerID, pg.Scope, pg.TeamID, - pg.CreatedAt, pg.UpdatedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import persona group %s: %w", pg.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportPersonaGroupMembers(ctx context.Context, pgms []models.PersonaGroupMember) (int, int, error) { - imported, skipped := 0, 0 - for _, pgm := range pgms { - r, err := DB.ExecContext(ctx, ` - INSERT INTO persona_group_members (id, group_id, persona_id, is_leader, sort_order) - VALUES ($1,$2,$3,$4,$5) - ON CONFLICT (id) DO NOTHING`, - pgm.ID, pgm.GroupID, pgm.PersonaID, pgm.IsLeader, pgm.SortOrder) - if err != nil { - return imported, skipped, fmt.Errorf("import persona group member %s: %w", pgm.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { imported++ } else { skipped++ } - } - return imported, skipped, nil -} diff --git a/server/store/postgres/file.go b/server/store/postgres/file.go deleted file mode 100644 index 1d7972c..0000000 --- a/server/store/postgres/file.go +++ /dev/null @@ -1,260 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "encoding/json" - "time" - - "switchboard-core/models" -) - -// FileStore implements store.FileStore against the `files` table. -// -type FileStore struct{} - -func NewFileStore() *FileStore { return &FileStore{} } - -const fileCols = `id, channel_id, user_id, message_id, project_id, origin, - filename, content_type, size_bytes, storage_key, display_hint, - extracted_text, metadata, created_at, updated_at` - -func scanFile(row interface{ Scan(dest ...interface{}) error }) (*models.File, error) { - var f models.File - var messageID sql.NullString - var projectID sql.NullString - var extractedText sql.NullString - var metadataJSON []byte - - err := row.Scan( - &f.ID, &f.ChannelID, &f.UserID, &messageID, &projectID, &f.Origin, - &f.Filename, &f.ContentType, &f.SizeBytes, - &f.StorageKey, &f.DisplayHint, - &extractedText, &metadataJSON, &f.CreatedAt, &f.UpdatedAt, - ) - if err != nil { - return nil, err - } - - f.MessageID = NullableStringPtr(messageID) - f.ProjectID = NullableStringPtr(projectID) - if extractedText.Valid { - f.ExtractedText = &extractedText.String - } - if len(metadataJSON) > 0 { - json.Unmarshal(metadataJSON, &f.Metadata) - } - return &f, nil -} - -func (s *FileStore) Create(ctx context.Context, f *models.File) error { - if f.Origin == "" { - f.Origin = models.FileOriginUserUpload - } - if f.DisplayHint == "" { - f.DisplayHint = models.FileHintDownload - } - return DB.QueryRowContext(ctx, ` - INSERT INTO files (channel_id, user_id, message_id, project_id, origin, - filename, content_type, size_bytes, storage_key, display_hint, - extracted_text, metadata) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) - RETURNING id, created_at, updated_at`, - f.ChannelID, f.UserID, models.NullString(f.MessageID), - models.NullString(f.ProjectID), f.Origin, - f.Filename, f.ContentType, f.SizeBytes, - f.StorageKey, f.DisplayHint, models.NullString(f.ExtractedText), - ToJSON(f.Metadata), - ).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt) -} - -func (s *FileStore) GetByID(ctx context.Context, id string) (*models.File, error) { - row := DB.QueryRowContext(ctx, `SELECT `+fileCols+` FROM files WHERE id = $1`, id) - return scanFile(row) -} - -func (s *FileStore) GetByChannel(ctx context.Context, channelID string, origin string) ([]models.File, error) { - var rows *sql.Rows - var err error - if origin != "" { - rows, err = DB.QueryContext(ctx, - `SELECT `+fileCols+` FROM files WHERE channel_id = $1 AND origin = $2 ORDER BY created_at`, - channelID, origin) - } else { - rows, err = DB.QueryContext(ctx, - `SELECT `+fileCols+` FROM files WHERE channel_id = $1 ORDER BY created_at`, - channelID) - } - if err != nil { - return nil, err - } - defer rows.Close() - - var out []models.File - for rows.Next() { - f, err := scanFile(rows) - if err != nil { - return nil, err - } - out = append(out, *f) - } - return out, rows.Err() -} - -func (s *FileStore) GetByMessage(ctx context.Context, messageID string) ([]models.File, error) { - rows, err := DB.QueryContext(ctx, - `SELECT `+fileCols+` FROM files WHERE message_id = $1 ORDER BY created_at`, messageID) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []models.File - for rows.Next() { - f, err := scanFile(rows) - if err != nil { - return nil, err - } - out = append(out, *f) - } - return out, rows.Err() -} - -func (s *FileStore) GetByProject(ctx context.Context, projectID string) ([]models.File, error) { - rows, err := DB.QueryContext(ctx, - `SELECT `+fileCols+` FROM files WHERE project_id = $1 ORDER BY created_at`, projectID) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []models.File - for rows.Next() { - f, err := scanFile(rows) - if err != nil { - return nil, err - } - out = append(out, *f) - } - return out, rows.Err() -} - -func (s *FileStore) GetByUser(ctx context.Context, userID string, page, perPage int) ([]models.File, int, error) { - var total int - err := DB.QueryRowContext(ctx, - `SELECT COUNT(*) FROM files WHERE user_id = $1`, userID).Scan(&total) - if err != nil { - return nil, 0, err - } - - offset := (page - 1) * perPage - rows, err := DB.QueryContext(ctx, - `SELECT `+fileCols+` FROM files WHERE user_id = $1 - ORDER BY created_at DESC LIMIT $2 OFFSET $3`, userID, perPage, offset) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var out []models.File - for rows.Next() { - f, err := scanFile(rows) - if err != nil { - return nil, total, err - } - out = append(out, *f) - } - return out, total, rows.Err() -} - -func (s *FileStore) SetMessageID(ctx context.Context, fileID, messageID string) error { - _, err := DB.ExecContext(ctx, - `UPDATE files SET message_id = $1, updated_at = NOW() WHERE id = $2`, - messageID, fileID) - return err -} - -func (s *FileStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error { - metaJSON, err := json.Marshal(metadata) - if err != nil { - return err - } - _, err = DB.ExecContext(ctx, - `UPDATE files SET metadata = metadata || $1::jsonb, updated_at = NOW() WHERE id = $2`, - metaJSON, id) - return err -} - -func (s *FileStore) SetExtractedText(ctx context.Context, id string, text string) error { - _, err := DB.ExecContext(ctx, - `UPDATE files SET extracted_text = $1, updated_at = NOW() WHERE id = $2`, - text, id) - return err -} - -func (s *FileStore) Delete(ctx context.Context, id string) (*models.File, error) { - row := DB.QueryRowContext(ctx, - `DELETE FROM files WHERE id = $1 RETURNING `+fileCols, id) - return scanFile(row) -} - -func (s *FileStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) { - rows, err := DB.QueryContext(ctx, - `DELETE FROM files WHERE channel_id = $1 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 *FileStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) { - var total sql.NullInt64 - err := DB.QueryRowContext(ctx, - `SELECT COALESCE(SUM(size_bytes), 0) FROM files WHERE user_id = $1`, - userID).Scan(&total) - if err != nil { - return 0, err - } - return total.Int64, nil -} - -func (s *FileStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.File, error) { - cutoff := time.Now().Add(-olderThan) - rows, err := DB.QueryContext(ctx, - `SELECT `+fileCols+` FROM files - WHERE message_id IS NULL AND origin = 'user_upload' AND created_at < $1 - ORDER BY created_at`, cutoff) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []models.File - for rows.Next() { - f, err := scanFile(rows) - if err != nil { - return nil, err - } - out = append(out, *f) - } - return out, rows.Err() -} - -// ── CS5c additions (v0.29.0) ────────────────────────────────────────── - -func (s *FileStore) UpdateStorageKey(ctx context.Context, id, key string) error { - _, err := DB.ExecContext(ctx, - `UPDATE files SET storage_key = $1, updated_at = NOW() WHERE id = $2`, - key, id) - return err -} diff --git a/server/store/postgres/folder.go b/server/store/postgres/folder.go deleted file mode 100644 index 1987a2c..0000000 --- a/server/store/postgres/folder.go +++ /dev/null @@ -1,88 +0,0 @@ -package postgres - -import ( - "context" - - "switchboard-core/models" -) - -type FolderStore struct{} - -func NewFolderStore() *FolderStore { return &FolderStore{} } - -func (s *FolderStore) List(ctx context.Context, userID string) ([]models.Folder, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, parent_id, sort_order, created_at, updated_at - FROM folders WHERE user_id = $1 - ORDER BY sort_order, name - `, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.Folder - for rows.Next() { - var f models.Folder - if err := rows.Scan(&f.ID, &f.Name, &f.ParentID, &f.SortOrder, - &f.CreatedAt, &f.UpdatedAt); err != nil { - continue - } - f.UserID = userID - result = append(result, f) - } - if result == nil { - result = []models.Folder{} - } - return result, rows.Err() -} - -func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error { - return DB.QueryRowContext(ctx, ` - INSERT INTO folders (user_id, name, parent_id, sort_order) - VALUES ($1, $2, $3, $4) - RETURNING id, name, parent_id, sort_order, created_at, updated_at - `, f.UserID, f.Name, f.ParentID, f.SortOrder).Scan( - &f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt) -} - -func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int, parentID **string) (int64, error) { - if parentID != nil { - res, err := DB.ExecContext(ctx, ` - UPDATE folders - SET name = COALESCE(NULLIF($3, ''), name), - sort_order = COALESCE($4, sort_order), - parent_id = $5 - WHERE id = $1 AND user_id = $2 - `, folderID, userID, name, sortOrder, *parentID) - if err != nil { - return 0, err - } - return res.RowsAffected() - } - res, err := DB.ExecContext(ctx, ` - UPDATE folders - SET name = COALESCE(NULLIF($3, ''), name), - sort_order = COALESCE($4, sort_order) - WHERE id = $1 AND user_id = $2 - `, folderID, userID, name, sortOrder) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *FolderStore) Delete(ctx context.Context, folderID, userID string) (int64, error) { - res, err := DB.ExecContext(ctx, - `DELETE FROM folders WHERE id = $1 AND user_id = $2`, folderID, userID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *FolderStore) UnassignChannels(ctx context.Context, folderID, userID string) error { - _, err := DB.ExecContext(ctx, - `UPDATE channels SET folder_id = NULL WHERE folder_id = $1 AND user_id = $2`, folderID, userID) - return err -} diff --git a/server/store/postgres/git_credentials.go b/server/store/postgres/git_credentials.go deleted file mode 100644 index 1efbeaf..0000000 --- a/server/store/postgres/git_credentials.go +++ /dev/null @@ -1,80 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - - "switchboard-core/models" -) - -// GitCredentialStore implements store.GitCredentialStore for PostgreSQL. -type GitCredentialStore struct{} - -func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredential) error { - if cred.ID != "" { - return DB.QueryRowContext(ctx, ` - INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING created_at`, - cred.ID, cred.UserID, cred.Name, cred.AuthType, - cred.EncryptedData, cred.Nonce, - cred.PublicKey, cred.Fingerprint, cred.PersonaID, - ).Scan(&cred.CreatedAt) - } - return DB.QueryRowContext(ctx, ` - INSERT INTO git_credentials (user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, created_at`, - cred.UserID, cred.Name, cred.AuthType, - cred.EncryptedData, cred.Nonce, - cred.PublicKey, cred.Fingerprint, cred.PersonaID, - ).Scan(&cred.ID, &cred.CreatedAt) -} - -func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.GitCredential, error) { - var c models.GitCredential - err := DB.QueryRowContext(ctx, ` - SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at - FROM git_credentials WHERE id = $1`, id, - ).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType, - &c.EncryptedData, &c.Nonce, - &c.PublicKey, &c.Fingerprint, &c.PersonaID, &c.CreatedAt) - if err != nil { - return nil, err - } - return &c, nil -} - -func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at - FROM git_credentials WHERE user_id = $1 ORDER BY created_at DESC`, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - var creds []models.GitCredential - for rows.Next() { - var c models.GitCredential - if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType, - &c.EncryptedData, &c.Nonce, - &c.PublicKey, &c.Fingerprint, &c.PersonaID, &c.CreatedAt); err != nil { - return nil, err - } - creds = append(creds, c) - } - return creds, rows.Err() -} - -func (s *GitCredentialStore) Delete(ctx context.Context, id, userID string) error { - res, err := DB.ExecContext(ctx, `DELETE FROM git_credentials WHERE id = $1 AND user_id = $2`, id, userID) - if err != nil { - return err - } - n, _ := res.RowsAffected() - if n == 0 { - return sql.ErrNoRows - } - return nil -} diff --git a/server/store/postgres/knowledge_bases.go b/server/store/postgres/knowledge_bases.go deleted file mode 100644 index 0a52a6b..0000000 --- a/server/store/postgres/knowledge_bases.go +++ /dev/null @@ -1,546 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "fmt" - "strings" - - "switchboard-core/models" - - "github.com/lib/pq" -) - -// ── KnowledgeBaseStore ────────────────────────── - -type KnowledgeBaseStore struct{} - -func NewKnowledgeBaseStore() *KnowledgeBaseStore { return &KnowledgeBaseStore{} } - -// ── KB CRUD ────────────────────────────────────── - -func (s *KnowledgeBaseStore) Create(ctx context.Context, kb *models.KnowledgeBase) error { - return DB.QueryRowContext(ctx, ` - INSERT INTO knowledge_bases (name, description, scope, owner_id, team_id, embedding_config, status) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, document_count, chunk_count, total_bytes, created_at, updated_at`, - kb.Name, kb.Description, kb.Scope, - models.NullString(kb.OwnerID), models.NullString(kb.TeamID), - ToJSON(kb.EmbeddingConfig), kb.Status, - ).Scan(&kb.ID, &kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes, &kb.CreatedAt, &kb.UpdatedAt) -} - -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 = $1`, 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.Set("updated_at", "now()") - 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 { - // CASCADE deletes kb_documents, kb_chunks, channel_knowledge_bases. - _, err := DB.ExecContext(ctx, "DELETE FROM knowledge_bases WHERE id = $1", id) - return err -} - -// ── Scoped Listing ────────────────────────────── - -func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) { - // User can see: global + their teams + personal + group-granted. - 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 = $1)` - - args := []interface{}{userID} - - if len(teamIDs) > 0 { - q += fmt.Sprintf(` OR (scope = 'team' AND team_id = ANY($%d))`, len(args)+1) - args = append(args, pq.Array(teamIDs)) - } - - // Group-granted KBs - q += fmt.Sprintf(` - 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 - WHERE gm.user_id = $%d - AND gm.group_id = ANY(rg.granted_groups) - )) - ) - )`, len(args)+1) - args = append(args, userID) - - q += ` ORDER BY name` - return queryKBs(ctx, q, args...) -} - -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 = $1 ORDER BY name`, userID) -} - -// ── Documents ──────────────────────────────────── - -func (s *KnowledgeBaseStore) CreateDocument(ctx context.Context, doc *models.KBDocument) error { - return DB.QueryRowContext(ctx, ` - INSERT INTO kb_documents (kb_id, filename, content_type, size_bytes, storage_key, status, uploaded_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, chunk_count, created_at, updated_at`, - doc.KBID, doc.Filename, doc.ContentType, doc.SizeBytes, - doc.StorageKey, doc.Status, doc.UploadedBy, - ).Scan(&doc.ID, &doc.ChunkCount, &doc.CreatedAt, &doc.UpdatedAt) -} - -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 = $1`, id).Scan( - &doc.ID, &doc.KBID, &doc.Filename, &doc.ContentType, &doc.SizeBytes, - &doc.StorageKey, &extractedText, &doc.ChunkCount, &doc.Status, - &errMsg, &doc.UploadedBy, &doc.CreatedAt, &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 = $1 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, &doc.CreatedAt, &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 = $2, error = $3, updated_at = now() - WHERE id = $1`, id, status, models.NullString(errMsg)) - 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 = $2, chunk_count = $3, updated_at = now() - WHERE id = $1`, id, text, chunkCount) - return err -} - -func (s *KnowledgeBaseStore) UpdateDocumentStorageKey(ctx context.Context, id string, storageKey string) error { - _, err := DB.ExecContext(ctx, ` - UPDATE kb_documents SET storage_key = $2 WHERE id = $1`, id, storageKey) - return err -} - -func (s *KnowledgeBaseStore) DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) { - var doc models.KBDocument - var errMsg sql.NullString - // Return the row before deleting so caller can clean up storage. - err := DB.QueryRowContext(ctx, ` - DELETE FROM kb_documents WHERE id = $1 - 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 a single multi-row INSERT. - // Embedding vectors are inserted as text representations that pgvector parses. - const cols = 7 // kb_id, document_id, chunk_index, content, token_count, embedding, metadata - valueParts := make([]string, 0, len(chunks)) - args := make([]interface{}, 0, len(chunks)*cols) - - for i, c := range chunks { - base := i * cols - valueParts = append(valueParts, fmt.Sprintf( - "($%d, $%d, $%d, $%d, $%d, $%d::vector, $%d)", - base+1, base+2, base+3, base+4, base+5, base+6, base+7, - )) - args = append(args, c.KBID, c.DocumentID, c.ChunkIndex, - c.Content, c.TokenCount, vectorToString(c.Embedding), ToJSON(c.Metadata)) - } - - q := fmt.Sprintf(`INSERT INTO kb_chunks (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 = $1", documentID) - return err -} - -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 - } - - rows, err := DB.QueryContext(ctx, ` - SELECT c.content, c.metadata, d.filename, kb.name, - 1 - (c.embedding <=> $1::vector) AS similarity - 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 = ANY($2) - AND 1 - (c.embedding <=> $1::vector) > $3 - ORDER BY c.embedding <=> $1::vector - LIMIT $4`, - vectorToString(queryVec), pq.Array(kbIDs), threshold, limit, - ) - if err != nil { - return nil, err - } - defer rows.Close() - - var results []models.KBSearchResult - for rows.Next() { - var r models.KBSearchResult - var metadataJSON []byte - err := rows.Scan(&r.Content, &metadataJSON, &r.Filename, &r.KBName, &r.Similarity) - if err != nil { - return nil, err - } - ScanJSON(metadataJSON, &r.Metadata) - results = append(results, r) - } - return results, rows.Err() -} - -// ── 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() - - // Clear existing links. - _, err = tx.ExecContext(ctx, "DELETE FROM channel_knowledge_bases WHERE channel_id = $1", channelID) - if err != nil { - return err - } - - // Insert new links. - for _, kbID := range kbIDs { - _, err = tx.ExecContext(ctx, ` - INSERT INTO channel_knowledge_bases (channel_id, kb_id, enabled) VALUES ($1, $2, true)`, - 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 = $1 - 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) { - // Return KB IDs that are: enabled on this channel AND user has access to. - q := ` - SELECT ckb.kb_id - FROM channel_knowledge_bases ckb - JOIN knowledge_bases kb ON ckb.kb_id = kb.id - WHERE ckb.channel_id = $1 AND ckb.enabled = true - AND ( - kb.scope = 'global' - OR (kb.scope = 'personal' AND kb.owner_id = $2)` - - args := []interface{}{channelID, userID} - - if len(teamIDs) > 0 { - q += fmt.Sprintf(` OR (kb.scope = 'team' AND kb.team_id = ANY($%d))`, len(args)+1) - args = append(args, pq.Array(teamIDs)) - } - - 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 = $1 AND status != 'error'), - chunk_count = (SELECT COUNT(*) FROM kb_chunks WHERE kb_id = $1), - total_bytes = COALESCE((SELECT SUM(size_bytes) FROM kb_documents WHERE kb_id = $1), 0), - updated_at = now() - WHERE id = $1`, 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 = $2, updated_at = now() - WHERE id = $1`, kbID, discoverable) - return err -} - -// GetActiveKBIDsWithPersona returns KB IDs accessible for a channel, including -// KBs bound to the active persona. Persona-bound KBs are included even if -// they are not discoverable and not channel-linked. -func (s *KnowledgeBaseStore) GetActiveKBIDsWithPersona(ctx context.Context, channelID string, userID string, teamIDs []string, personaID string) ([]string, error) { - // Start with channel-linked KBs (existing behavior) - q := ` - SELECT ckb.kb_id - FROM channel_knowledge_bases ckb - JOIN knowledge_bases kb ON ckb.kb_id = kb.id - WHERE ckb.channel_id = $1 AND ckb.enabled = true - AND ( - kb.scope = 'global' - OR (kb.scope = 'personal' AND kb.owner_id = $2)` - - args := []interface{}{channelID, userID} - - if len(teamIDs) > 0 { - q += fmt.Sprintf(` OR (kb.scope = 'team' AND kb.team_id = ANY($%d))`, len(args)+1) - args = append(args, pq.Array(teamIDs)) - } - - q += `)` - - // UNION with persona-bound KBs (bypass discoverable check) - if personaID != "" { - q += fmt.Sprintf(` - UNION - SELECT pkb.kb_id - FROM persona_knowledge_bases pkb - JOIN knowledge_bases kb ON pkb.kb_id = kb.id - WHERE pkb.persona_id = $%d - AND kb.chunk_count > 0`, len(args)+1) - 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. -// Used for the channel KB toggle popup when kb_direct_access is enabled. -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 = true - AND ( - scope = 'global' - OR (scope = 'personal' AND owner_id = $1)` - - args := []interface{}{userID} - - if len(teamIDs) > 0 { - q += fmt.Sprintf(` OR (scope = 'team' AND team_id = ANY($%d))`, len(args)+1) - args = append(args, pq.Array(teamIDs)) - } - - // Group-granted KBs - q += fmt.Sprintf(` - 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 - WHERE gm.user_id = $%d - AND gm.group_id = ANY(rg.granted_groups) - )) - ) - )`, len(args)+1) - 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 []byte - - err := row.Scan( - &kb.ID, &kb.Name, &kb.Description, &kb.Scope, - &ownerID, &teamID, &embCfgJSON, - &kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes, - &kb.Status, &kb.Discoverable, &kb.CreatedAt, &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 []byte - err := rows.Scan( - &kb.ID, &kb.Name, &kb.Description, &kb.Scope, - &ownerID, &teamID, &embCfgJSON, - &kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes, - &kb.Status, &kb.Discoverable, &kb.CreatedAt, &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() -} - -// vectorToString converts a float64 slice to pgvector text format: [0.1,0.2,0.3] -func vectorToString(v []float64) string { - if len(v) == 0 { - return "" - } - parts := make([]string, len(v)) - for i, f := range v { - parts[i] = fmt.Sprintf("%g", f) - } - return "[" + strings.Join(parts, ",") + "]" -} diff --git a/server/store/postgres/memory.go b/server/store/postgres/memory.go deleted file mode 100644 index 329b79f..0000000 --- a/server/store/postgres/memory.go +++ /dev/null @@ -1,336 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "fmt" - "strings" - - "switchboard-core/models" - "switchboard-core/store" -) - -type MemoryStore struct{} - -func NewMemoryStore() *MemoryStore { return &MemoryStore{} } - -func (s *MemoryStore) Upsert(ctx context.Context, m *models.Memory) error { - if m.ID == "" { - m.ID = store.NewID() - } - if m.Status == "" { - m.Status = models.MemoryStatusActive - } - if m.Confidence == 0 { - m.Confidence = 1.0 - } - - _, err := DB.ExecContext(ctx, ` - INSERT INTO memories (id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT (scope, owner_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'), key) - DO UPDATE SET - value = EXCLUDED.value, - confidence = EXCLUDED.confidence, - status = EXCLUDED.status, - source_channel_id = EXCLUDED.source_channel_id, - updated_at = now() - `, m.ID, m.Scope, m.OwnerID, m.UserID, m.Key, m.Value, - m.SourceChannelID, m.Confidence, m.Status) - return err -} - -func (s *MemoryStore) Update(ctx context.Context, m *models.Memory) error { - _, err := DB.ExecContext(ctx, ` - UPDATE memories - SET key = $1, value = $2, confidence = $3, status = $4, - source_channel_id = $5, updated_at = now() - WHERE id = $6 - `, m.Key, m.Value, m.Confidence, m.Status, m.SourceChannelID, m.ID) - return err -} - -func (s *MemoryStore) GetByID(ctx context.Context, id string) (*models.Memory, error) { - m := &models.Memory{} - err := DB.QueryRowContext(ctx, ` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories WHERE id = $1 - `, id).Scan( - &m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value, - &m.SourceChannelID, &m.Confidence, &m.Status, &m.CreatedAt, &m.UpdatedAt, - ) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("memory not found: %s", id) - } - return m, err -} - -func (s *MemoryStore) List(ctx context.Context, f models.MemoryFilter) ([]models.Memory, error) { - clauses := []string{"scope = $1", "owner_id = $2"} - args := []interface{}{f.Scope, f.OwnerID} - idx := 3 - - if f.UserID != nil { - clauses = append(clauses, fmt.Sprintf("user_id = $%d", idx)) - args = append(args, *f.UserID) - idx++ - } - - status := f.Status - if status == "" { - status = models.MemoryStatusActive - } - clauses = append(clauses, fmt.Sprintf("status = $%d", idx)) - args = append(args, status) - idx++ - - if f.Query != "" { - clauses = append(clauses, fmt.Sprintf( - "to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx)) - args = append(args, f.Query) - idx++ - } - - limit := f.Limit - if limit <= 0 || limit > 200 { - limit = 50 - } - args = append(args, limit) - - query := fmt.Sprintf(` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories - WHERE %s - ORDER BY confidence DESC, updated_at DESC - LIMIT $%d - `, strings.Join(clauses, " AND "), idx) - - rows, err := DB.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - return scanMemories(rows) -} - -func (s *MemoryStore) Delete(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, `DELETE FROM memories WHERE id = $1`, id) - return err -} - -func (s *MemoryStore) Archive(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, ` - UPDATE memories SET status = 'archived', updated_at = now() WHERE id = $1 - `, id) - return err -} - -func (s *MemoryStore) Recall(ctx context.Context, userID string, personaID *string, query string, limit int) ([]models.Memory, error) { - if limit <= 0 || limit > 100 { - limit = 20 - } - - // Build a UNION query across applicable scopes. - // Always include user-scope memories for this user. - // If a persona is active, also include persona and persona+user memories. - - parts := []string{} - args := []interface{}{} - idx := 1 - - // 1. User-scope memories - userPart := fmt.Sprintf(` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories - WHERE scope = 'user' AND owner_id = $%d AND status = 'active' - `, idx) - args = append(args, userID) - idx++ - - if query != "" { - userPart += fmt.Sprintf( - " AND to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx) - args = append(args, query) - idx++ - } - parts = append(parts, userPart) - - // 2. Persona-scope memories (if persona active) - if personaID != nil && *personaID != "" { - personaPart := fmt.Sprintf(` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories - WHERE scope = 'persona' AND owner_id = $%d AND status = 'active' - `, idx) - args = append(args, *personaID) - idx++ - if query != "" { - personaPart += fmt.Sprintf( - " AND to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx) - args = append(args, query) - idx++ - } - parts = append(parts, personaPart) - - // 3. Persona+user memories - puPart := fmt.Sprintf(` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories - WHERE scope = 'persona_user' AND owner_id = $%d AND user_id = $%d AND status = 'active' - `, idx, idx+1) - args = append(args, *personaID, userID) - idx += 2 - if query != "" { - puPart += fmt.Sprintf( - " AND to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx) - args = append(args, query) - idx++ - } - parts = append(parts, puPart) - } - - // Scope priority: persona_user > persona > user (CASE ordering) - fullQuery := fmt.Sprintf(` - SELECT * FROM (%s) AS combined - ORDER BY - CASE scope - WHEN 'persona_user' THEN 0 - WHEN 'persona' THEN 1 - WHEN 'user' THEN 2 - END, - confidence DESC, - updated_at DESC - LIMIT $%d - `, strings.Join(parts, " UNION ALL "), idx) - args = append(args, limit) - - rows, err := DB.QueryContext(ctx, fullQuery, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - return scanMemories(rows) -} - -func (s *MemoryStore) CountByOwner(ctx context.Context, scope, ownerID string) (int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM memories - WHERE scope = $1 AND owner_id = $2 AND status = 'active' - `, scope, ownerID).Scan(&count) - return count, err -} - -func (s *MemoryStore) ListByStatus(ctx context.Context, status string, limit int) ([]models.Memory, error) { - if limit <= 0 || limit > 500 { - limit = 100 - } - rows, err := DB.QueryContext(ctx, ` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories - WHERE status = $1 - ORDER BY created_at DESC - LIMIT $2 - `, status, limit) - if err != nil { - return nil, err - } - defer rows.Close() - return scanMemories(rows) -} - -// ── Helpers ───────────────────────────────── - -func scanMemories(rows *sql.Rows) ([]models.Memory, error) { - var memories []models.Memory - for rows.Next() { - var m models.Memory - if err := rows.Scan( - &m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value, - &m.SourceChannelID, &m.Confidence, &m.Status, &m.CreatedAt, &m.UpdatedAt, - ); err != nil { - return nil, err - } - memories = append(memories, m) - } - if memories == nil { - memories = []models.Memory{} - } - return memories, rows.Err() -} - -// ensure compile-time interface satisfaction -var _ store.MemoryStore = (*MemoryStore)(nil) - -// ── CS5b additions (v0.29.0) ──────────────────────────────────────────── - -func (s *MemoryStore) SetEmbedding(ctx context.Context, id, embedding string) error { - _, err := DB.ExecContext(ctx, - `UPDATE memories SET embedding = $1::vector WHERE id = $2`, embedding, id) - return err -} - -func (s *MemoryStore) GetLastExtractionMessageID(ctx context.Context, channelID, userID string) (string, error) { - var lastID string - err := DB.QueryRowContext(ctx, - `SELECT last_message_id FROM memory_extraction_log WHERE channel_id = $1 AND user_id = $2`, - channelID, userID).Scan(&lastID) - if err != nil { - return "", nil // no entry yet - } - return lastID, nil -} - -func (s *MemoryStore) UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO memory_extraction_log (channel_id, user_id, last_message_id, memory_count) - VALUES ($1, $2, $3, $4) - ON CONFLICT(channel_id, user_id) DO UPDATE SET - last_message_id = EXCLUDED.last_message_id, - extracted_at = now(), - memory_count = memory_extraction_log.memory_count + EXCLUDED.memory_count - `, channelID, userID, lastMessageID, count) - return err -} - -// ── v0.30.1 — Memory Compaction ──────────────────────────────────────── - -func (s *MemoryStore) DecayConfidence(ctx context.Context, olderThan string, decayFactor float64) (int, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE memories - SET confidence = confidence * $1, - updated_at = now() - WHERE status = 'active' - AND updated_at < $2::timestamptz - AND confidence > 0.1 - `, decayFactor, olderThan) - if err != nil { - return 0, err - } - n, _ := res.RowsAffected() - return int(n), nil -} - -func (s *MemoryStore) Prune(ctx context.Context, belowConfidence float64) (int, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE memories - SET status = 'archived', - updated_at = now() - WHERE status = 'active' - AND confidence < $1 - `, belowConfidence) - if err != nil { - return 0, err - } - n, _ := res.RowsAffected() - return int(n), nil -} diff --git a/server/store/postgres/memory_hybrid.go b/server/store/postgres/memory_hybrid.go deleted file mode 100644 index 09efa82..0000000 --- a/server/store/postgres/memory_hybrid.go +++ /dev/null @@ -1,131 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - "strings" - - "switchboard-core/models" -) - -// RecallHybrid returns active memories using vector similarity + keyword search. -// If queryVec is nil/empty, falls back to keyword-only Recall. -func (s *MemoryStore) RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error) { - if len(queryVec) == 0 { - return s.Recall(ctx, userID, personaID, query, limit) - } - if limit <= 0 || limit > 100 { - limit = 30 - } - - // Run semantic recall - semantic, err := s.recallSemantic(ctx, userID, personaID, queryVec, limit) - if err != nil { - // Fall back to keyword - return s.Recall(ctx, userID, personaID, query, limit) - } - - // Run keyword recall if query provided - var keyword []models.Memory - if query != "" { - keyword, _ = s.Recall(ctx, userID, personaID, query, limit) - } - - return mergeResults(semantic, keyword, limit), nil -} - -// recallSemantic uses pgvector cosine distance to find similar memories. -func (s *MemoryStore) recallSemantic(ctx context.Context, userID string, personaID *string, queryVec []float64, limit int) ([]models.Memory, error) { - vecStr := vectorToString(queryVec) - - parts := []string{} - args := []interface{}{} - idx := 1 - - // User-scope - userPart := fmt.Sprintf(` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at, - (embedding <=> $%d::vector) AS distance - FROM memories - WHERE scope = 'user' AND owner_id = $%d AND status = 'active' - AND embedding IS NOT NULL - `, idx, idx+1) - args = append(args, vecStr, userID) - idx += 2 - parts = append(parts, userPart) - - if personaID != nil && *personaID != "" { - // Persona-scope - personaPart := fmt.Sprintf(` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at, - (embedding <=> $%d::vector) AS distance - FROM memories - WHERE scope = 'persona' AND owner_id = $%d AND status = 'active' - AND embedding IS NOT NULL - `, idx, idx+1) - args = append(args, vecStr, *personaID) - idx += 2 - parts = append(parts, personaPart) - - // Persona+user scope - puPart := fmt.Sprintf(` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at, - (embedding <=> $%d::vector) AS distance - FROM memories - WHERE scope = 'persona_user' AND owner_id = $%d AND user_id = $%d AND status = 'active' - AND embedding IS NOT NULL - `, idx, idx+1, idx+2) - args = append(args, vecStr, *personaID, userID) - idx += 3 - parts = append(parts, puPart) - } - - args = append(args, limit) - fullQuery := fmt.Sprintf(` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM (%s) AS combined - WHERE distance < 0.7 - ORDER BY distance ASC, confidence DESC - LIMIT $%d - `, strings.Join(parts, " UNION ALL "), idx) - - rows, err := DB.QueryContext(ctx, fullQuery, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - return scanMemories(rows) -} - -// mergeResults deduplicates and merges semantic + keyword results. -func mergeResults(semantic, keyword []models.Memory, limit int) []models.Memory { - seen := make(map[string]bool, len(semantic)) - result := make([]models.Memory, 0, limit) - - // Semantic results first (higher relevance) - for _, m := range semantic { - if len(result) >= limit { - break - } - seen[m.ID] = true - result = append(result, m) - } - - // Then keyword results not already included - for _, m := range keyword { - if len(result) >= limit { - break - } - if !seen[m.ID] { - seen[m.ID] = true - result = append(result, m) - } - } - - return result -} diff --git a/server/store/postgres/message.go b/server/store/postgres/message.go deleted file mode 100644 index 03f1167..0000000 --- a/server/store/postgres/message.go +++ /dev/null @@ -1,306 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "time" - - "switchboard-core/models" - "switchboard-core/store" -) - -type MessageStore struct{} - -func NewMessageStore() *MessageStore { return &MessageStore{} } - -func (s *MessageStore) Create(ctx context.Context, m *models.Message) error { - toolCallsJSON := ToJSON(m.ToolCalls) - metadataJSON := ToJSON(m.Metadata) - return DB.QueryRowContext(ctx, ` - INSERT INTO messages (channel_id, role, content, model, tokens_used, tool_calls, - metadata, parent_id, sibling_index, participant_type, participant_id) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) - RETURNING id, created_at`, - m.ChannelID, m.Role, m.Content, m.Model, m.TokensUsed, - toolCallsJSON, metadataJSON, - models.NullString(m.ParentID), m.SiblingIndex, - m.ParticipantType, m.ParticipantID, - ).Scan(&m.ID, &m.CreatedAt) -} - -func (s *MessageStore) GetByID(ctx context.Context, id string) (*models.Message, error) { - var m models.Message - var parentID sql.NullString - var toolCallsJSON, metadataJSON []byte - var deletedAt sql.NullTime - err := DB.QueryRowContext(ctx, ` - SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata, - parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at - FROM messages WHERE id = $1`, id).Scan( - &m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, - &toolCallsJSON, &metadataJSON, - &parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID, - &deletedAt, &m.CreatedAt, - ) - if err != nil { - return nil, err - } - m.ParentID = NullableStringPtr(parentID) - json.Unmarshal(toolCallsJSON, &m.ToolCalls) - json.Unmarshal(metadataJSON, &m.Metadata) - if deletedAt.Valid { - m.DeletedAt = &deletedAt.Time - } - return &m, nil -} - -func (s *MessageStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { - b := NewUpdate("messages") - for k, v := range fields { - if k == "tool_calls" || k == "metadata" { - b.SetJSON(k, v) - } else { - b.Set(k, v) - } - } - if !b.HasSets() { - return nil - } - b.Where("id", id) - _, err := b.Exec(DB) - return err -} - -func (s *MessageStore) Delete(ctx context.Context, id string) error { - now := time.Now() - _, err := DB.ExecContext(ctx, "UPDATE messages SET deleted_at = $1 WHERE id = $2", now, id) - return err -} - -func (s *MessageStore) ListForChannel(ctx context.Context, channelID string, opts store.ListOptions) ([]models.Message, error) { - b := NewSelect( - "id, channel_id, role, content, model, tokens_used, tool_calls, metadata, parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at", - "messages", - ).Where("channel_id = ?", channelID).WhereRaw("deleted_at IS NULL") - if opts.Sort == "" { - b.OrderBy("created_at", "ASC") - } - b.Paginate(opts) - - q, args := b.Build() - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - return scanMessages(rows) -} - -func (s *MessageStore) GetChildren(ctx context.Context, parentID string) ([]models.Message, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata, - parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at - FROM messages WHERE parent_id = $1 AND deleted_at IS NULL - ORDER BY sibling_index`, parentID) - if err != nil { - return nil, err - } - defer rows.Close() - return scanMessages(rows) -} - -func (s *MessageStore) GetSiblings(ctx context.Context, messageID string) ([]models.Message, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata, - parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at - FROM messages - WHERE parent_id = (SELECT parent_id FROM messages WHERE id = $1) - AND deleted_at IS NULL - ORDER BY sibling_index`, messageID) - if err != nil { - return nil, err - } - defer rows.Close() - return scanMessages(rows) -} - -func (s *MessageStore) GetPathToRoot(ctx context.Context, messageID string) ([]models.Message, error) { - rows, err := DB.QueryContext(ctx, ` - WITH RECURSIVE path AS ( - SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata, - parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at - FROM messages WHERE id = $1 - UNION ALL - SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata, - m.parent_id, m.sibling_index, m.participant_type, m.participant_id, m.deleted_at, m.created_at - FROM messages m JOIN path p ON m.id = p.parent_id - ) - SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata, - parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at - FROM path ORDER BY created_at ASC`, messageID) - if err != nil { - return nil, err - } - defer rows.Close() - return scanMessages(rows) -} - -func (s *MessageStore) GetNextSiblingIndex(ctx context.Context, parentID string) (int, error) { - var maxIdx sql.NullInt64 - err := DB.QueryRowContext(ctx, - "SELECT MAX(sibling_index) FROM messages WHERE parent_id = $1 AND deleted_at IS NULL", - parentID).Scan(&maxIdx) - if err != nil || !maxIdx.Valid { - return 0, err - } - return int(maxIdx.Int64) + 1, nil -} - -func (s *MessageStore) CountForChannel(ctx context.Context, channelID string) (int, error) { - var count int - err := DB.QueryRowContext(ctx, - "SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL", - channelID).Scan(&count) - return count, err -} - -func scanMessages(rows *sql.Rows) ([]models.Message, error) { - var result []models.Message - for rows.Next() { - var m models.Message - var parentID sql.NullString - var toolCallsJSON, metadataJSON []byte - var deletedAt sql.NullTime - err := rows.Scan( - &m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, - &toolCallsJSON, &metadataJSON, - &parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID, - &deletedAt, &m.CreatedAt, - ) - if err != nil { - return nil, err - } - m.ParentID = NullableStringPtr(parentID) - json.Unmarshal(toolCallsJSON, &m.ToolCalls) - json.Unmarshal(metadataJSON, &m.Metadata) - if deletedAt.Valid { - m.DeletedAt = &deletedAt.Time - } - result = append(result, m) - } - return result, rows.Err() -} - -// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *MessageStore) CountAll(ctx context.Context) (int, error) { - var count int - err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&count) - return count, err -} - -// ── CS5c additions (v0.29.0) ───────────────────────────────────────────── - -func (s *MessageStore) SearchInChannel(ctx context.Context, channelID, query, roleFilter string, limit int) ([]store.ChannelSearchResult, error) { - roleClause := "" - queryArgs := []interface{}{channelID, query, limit} - if roleFilter == "user" || roleFilter == "assistant" { - roleClause = "AND m.role = $4" - queryArgs = append(queryArgs, roleFilter) - } - - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT m.id, m.role, - ts_headline('english', m.content, plainto_tsquery('english', $2), - 'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline, - ts_rank(to_tsvector('english', m.content), plainto_tsquery('english', $2)) AS rank, - m.created_at - FROM messages m - WHERE m.channel_id = $1 - AND m.deleted_at IS NULL - AND m.role IN ('user', 'assistant') - AND to_tsvector('english', m.content) @@ plainto_tsquery('english', $2) - %s - ORDER BY rank DESC, m.created_at DESC - LIMIT $3 - `, roleClause), queryArgs...) - if err != nil { - return nil, err - } - defer rows.Close() - - results := make([]store.ChannelSearchResult, 0) - for rows.Next() { - var r store.ChannelSearchResult - if err := rows.Scan(&r.MessageID, &r.Role, &r.Excerpt, &r.Rank, &r.Timestamp); err != nil { - continue - } - results = append(results, r) - } - return results, rows.Err() -} - -// ── CS7a additions (v0.29.0) ──────────────────────────────────────────── - -func (s *MessageStore) ListWithSenderInfo(ctx context.Context, channelID string, limit, offset int) ([]store.MessageWithSender, int, error) { - var total int - err := DB.QueryRowContext(ctx, - `SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`, - channelID).Scan(&total) - if err != nil { - return nil, 0, err - } - - rows, err := DB.QueryContext(ctx, ` - SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id, - m.sibling_index, m.participant_type, m.participant_id, - CASE WHEN m.participant_type = 'user' THEN COALESCE(NULLIF(u.display_name, ''), u.username) - WHEN m.participant_type = 'persona' THEN p.name - ELSE NULL END AS sender_name, - CASE WHEN m.participant_type = 'user' THEN u.avatar_url - WHEN m.participant_type = 'persona' THEN p.avatar - ELSE NULL END AS sender_avatar, - m.created_at - FROM messages m - LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text - LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text - WHERE m.channel_id = $1 AND m.deleted_at IS NULL - ORDER BY m.created_at ASC - LIMIT $2 OFFSET $3 - `, channelID, limit, offset) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - results := make([]store.MessageWithSender, 0) - for rows.Next() { - var m store.MessageWithSender - if err := rows.Scan( - &m.ID, &m.ChannelID, &m.Role, &m.Content, - &m.Model, &m.TokensUsed, &m.ParentID, - &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID, - &m.SenderName, &m.SenderAvatar, - &m.CreatedAt, - ); err != nil { - return nil, total, err - } - results = append(results, m) - } - return results, total, rows.Err() -} - -func (s *MessageStore) GetParentAndRole(ctx context.Context, messageID, channelID string) (*string, string, error) { - var parentID sql.NullString - var role string - err := DB.QueryRowContext(ctx, ` - SELECT parent_id, role FROM messages - WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL - `, messageID, channelID).Scan(&parentID, &role) - if err != nil { - return nil, "", err - } - return NullableStringPtr(parentID), role, nil -} \ No newline at end of file diff --git a/server/store/postgres/message_tree.go b/server/store/postgres/message_tree.go deleted file mode 100644 index f27a227..0000000 --- a/server/store/postgres/message_tree.go +++ /dev/null @@ -1,383 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - - "switchboard-core/models" - "switchboard-core/store" -) - -// ── Tree Operations (v0.29.0) ─────────────────────────────────────────── -// Moved from treepath package. All message tree traversal goes through -// the store interface now. - -func (s *MessageStore) GetActiveLeaf(ctx context.Context, channelID, userID string) (*string, error) { - var leafID *string - - // Try cursor first - err := DB.QueryRowContext(ctx, ` - SELECT active_leaf_id FROM channel_cursors - WHERE channel_id = $1 AND user_id = $2 - `, channelID, userID).Scan(&leafID) - - if err == nil && leafID != nil { - // Verify the leaf still exists and isn't deleted - var exists bool - DB.QueryRowContext(ctx, ` - SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND deleted_at IS NULL) - `, *leafID).Scan(&exists) - if exists { - return leafID, nil - } - } - - // Fallback: latest live message in channel - var fallbackID string - err = DB.QueryRowContext(ctx, ` - SELECT id FROM messages - WHERE channel_id = $1 AND deleted_at IS NULL - ORDER BY created_at DESC LIMIT 1 - `, channelID).Scan(&fallbackID) - if err == sql.ErrNoRows { - return nil, nil // empty channel - } - if err != nil { - return nil, err - } - return &fallbackID, nil -} - -func (s *MessageStore) GetPathToLeaf(ctx context.Context, channelID, leafID string) ([]store.PathMessage, error) { - rows, err := DB.QueryContext(ctx, ` - WITH RECURSIVE path AS ( - SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, - participant_type, participant_id, sibling_index, created_at, - 0 AS depth - FROM messages - WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL - - UNION ALL - - SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata, - m.participant_type, m.participant_id, m.sibling_index, m.created_at, - p.depth + 1 - FROM messages m - JOIN path p ON m.id = p.parent_id - WHERE m.deleted_at IS NULL - ) - SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, - participant_type, participant_id, sibling_index, created_at - FROM path - ORDER BY depth DESC - `, leafID, channelID) - if err != nil { - return nil, fmt.Errorf("GetPathToLeaf: %w", err) - } - defer rows.Close() - - var path []store.PathMessage - for rows.Next() { - var m store.PathMessage - var participantType, participantID sql.NullString - var toolCallsJSON, metadataJSON []byte - if err := rows.Scan( - &m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, - &toolCallsJSON, &metadataJSON, - &participantType, &participantID, &m.SiblingIndex, &m.CreatedAt, - ); err != nil { - return nil, fmt.Errorf("GetPathToLeaf scan: %w", err) - } - if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" { - raw := json.RawMessage(toolCallsJSON) - m.ToolCalls = &raw - } - if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" { - raw := json.RawMessage(metadataJSON) - m.Metadata = &raw - } - if participantType.Valid { - m.ParticipantType = participantType.String - } - if participantID.Valid { - m.ParticipantID = participantID.String - } - path = append(path, m) - } - if err := rows.Err(); err != nil { - return nil, err - } - - // Enrich with sibling counts - for i := range path { - count, _ := s.GetSiblingCount(ctx, channelID, path[i].ParentID) - path[i].SiblingCount = count - } - - // Resolve sender info - _ = s.ResolveSenderInfo(ctx, path) - - return path, nil -} - -func (s *MessageStore) GetActivePath(ctx context.Context, channelID, userID string) ([]store.PathMessage, error) { - leafID, err := s.GetActiveLeaf(ctx, channelID, userID) - if err != nil { - return nil, err - } - if leafID == nil { - return []store.PathMessage{}, nil - } - return s.GetPathToLeaf(ctx, channelID, *leafID) -} - -func (s *MessageStore) GetSiblingsList(ctx context.Context, messageID string) ([]store.SiblingInfo, int, error) { - // Get parent_id and channel_id of the target message - var parentID *string - var channelID string - err := DB.QueryRowContext(ctx, ` - SELECT parent_id, channel_id FROM messages - WHERE id = $1 AND deleted_at IS NULL - `, messageID).Scan(&parentID, &channelID) - if err != nil { - return nil, 0, fmt.Errorf("message not found: %w", err) - } - - var rows *sql.Rows - if parentID == nil { - rows, err = DB.QueryContext(ctx, ` - SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at - FROM messages - WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL - ORDER BY sibling_index, created_at - `, channelID) - } else { - rows, err = DB.QueryContext(ctx, ` - SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at - FROM messages - WHERE parent_id = $1 AND deleted_at IS NULL - ORDER BY sibling_index, created_at - `, *parentID) - } - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var siblings []store.SiblingInfo - currentIdx := 0 - for i := 0; rows.Next(); i++ { - var si store.SiblingInfo - if err := rows.Scan(&si.ID, &si.Role, &si.Model, &si.SiblingIndex, &si.Preview, &si.CreatedAt); err != nil { - return nil, 0, err - } - if si.ID == messageID { - currentIdx = i - } - siblings = append(siblings, si) - } - - return siblings, currentIdx, rows.Err() -} - -func (s *MessageStore) GetSiblingCount(ctx context.Context, channelID string, parentID *string) (int, error) { - var count int - var err error - if parentID == nil { - err = DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM messages - WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL - `, channelID).Scan(&count) - } else { - err = DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM messages - WHERE parent_id = $1 AND deleted_at IS NULL - `, *parentID).Scan(&count) - } - if err != nil || count == 0 { - return 1, nil // minimum 1 (the message itself) - } - return count, nil -} - -func (s *MessageStore) FindLeafFromMessage(ctx context.Context, messageID string) (string, error) { - var leafID string - err := DB.QueryRowContext(ctx, ` - WITH RECURSIVE descendants AS ( - SELECT id, 0 AS depth - FROM messages - WHERE id = $1 AND deleted_at IS NULL - - UNION ALL - - SELECT child.id, d.depth + 1 - FROM messages child - JOIN descendants d ON child.parent_id = d.id - WHERE child.deleted_at IS NULL - AND child.sibling_index = ( - SELECT MIN(sibling_index) FROM messages - WHERE parent_id = d.id AND deleted_at IS NULL - ) - ) - SELECT id FROM descendants - ORDER BY depth DESC - LIMIT 1 - `, messageID).Scan(&leafID) - - if err != nil { - return messageID, nil // fallback to message itself - } - return leafID, nil -} - -func (s *MessageStore) NextSiblingIndexForParent(ctx context.Context, channelID string, parentID *string) (int, error) { - var maxIdx sql.NullInt64 - var err error - if parentID == nil { - err = DB.QueryRowContext(ctx, ` - SELECT MAX(sibling_index) FROM messages - WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL - `, channelID).Scan(&maxIdx) - } else { - err = DB.QueryRowContext(ctx, ` - SELECT MAX(sibling_index) FROM messages - WHERE parent_id = $1 AND deleted_at IS NULL - `, *parentID).Scan(&maxIdx) - } - if err != nil || !maxIdx.Valid { - return 0, nil - } - return int(maxIdx.Int64) + 1, nil -} - -func (s *MessageStore) HasPersonaMessages(ctx context.Context, channelID string) (bool, error) { - var id string - err := DB.QueryRowContext(ctx, ` - SELECT id FROM messages - WHERE channel_id = $1 AND role = 'assistant' AND participant_type = 'persona' - LIMIT 1 - `, channelID).Scan(&id) - return err == nil && id != "", nil -} - -func (s *MessageStore) CreateWithCursor(ctx context.Context, m *models.Message, cursorUserID string) error { - // Insert message — PG generates ID via gen_random_uuid() - err := DB.QueryRowContext(ctx, ` - INSERT INTO messages (channel_id, role, content, model, tokens_used, - tool_calls, parent_id, participant_type, participant_id, - provider_config_id, sibling_index) - VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10, $11) - RETURNING id, created_at`, - m.ChannelID, m.Role, m.Content, safeModel(m.Model), m.TokensUsed, - ToJSON(m.ToolCalls), models.NullString(m.ParentID), - m.ParticipantType, m.ParticipantID, - safeString(m.ProviderConfigID), m.SiblingIndex, - ).Scan(&m.ID, &m.CreatedAt) - if err != nil { - return fmt.Errorf("CreateWithCursor insert: %w", err) - } - - // Update cursor - if cursorUserID != "" { - _, _ = DB.ExecContext(ctx, ` - INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) - VALUES ($1, $2, $3) - ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = $3, updated_at = NOW() - `, m.ChannelID, cursorUserID, m.ID) - } - - // Touch channel - _, _ = DB.ExecContext(ctx, `UPDATE channels SET updated_at = NOW() WHERE id = $1`, m.ChannelID) - - return nil -} - -func (s *MessageStore) ResolveSenderInfo(ctx context.Context, path []store.PathMessage) error { - // Collect unique participant IDs by type - userIDs := map[string]bool{} - personaIDs := map[string]bool{} - for _, m := range path { - if m.ParticipantID == "" { - continue - } - switch m.ParticipantType { - case "user": - userIDs[m.ParticipantID] = true - case "persona": - personaIDs[m.ParticipantID] = true - } - } - - // Resolve users - userNames := map[string]string{} - userAvatars := map[string]string{} - for uid := range userIDs { - var name, avatar sql.NullString - _ = DB.QueryRowContext(ctx, ` - SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1 - `, uid).Scan(&name, &avatar) - if name.Valid { - userNames[uid] = name.String - } - if avatar.Valid { - userAvatars[uid] = avatar.String - } - } - - // Resolve personas - personaNames := map[string]string{} - personaAvatars := map[string]string{} - for pid := range personaIDs { - var name, avatar sql.NullString - _ = DB.QueryRowContext(ctx, ` - SELECT name, avatar FROM personas WHERE id = $1 - `, pid).Scan(&name, &avatar) - if name.Valid { - personaNames[pid] = name.String - } - if avatar.Valid { - personaAvatars[pid] = avatar.String - } - } - - // Apply to path - for i := range path { - pid := path[i].ParticipantID - switch path[i].ParticipantType { - case "user": - if n, ok := userNames[pid]; ok { - path[i].SenderName = &n - } - if a, ok := userAvatars[pid]; ok && a != "" { - path[i].SenderAvatar = &a - } - case "persona": - if n, ok := personaNames[pid]; ok { - path[i].SenderName = &n - } - if a, ok := personaAvatars[pid]; ok && a != "" { - path[i].SenderAvatar = &a - } - } - } - - return nil -} - -// ── helpers ───────────────────────────────── - -func safeModel(m string) interface{} { - if m == "" { - return "" - } - return m -} - -func safeString(s *string) interface{} { - if s == nil || *s == "" { - return nil - } - return *s -} diff --git a/server/store/postgres/note.go b/server/store/postgres/note.go deleted file mode 100644 index 5872b4e..0000000 --- a/server/store/postgres/note.go +++ /dev/null @@ -1,318 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "strings" - - "switchboard-core/models" - "switchboard-core/store" - - "github.com/lib/pq" -) - -type NoteStore struct{} - -func NewNoteStore() *NoteStore { return &NoteStore{} } - -func (s *NoteStore) Create(ctx context.Context, n *models.Note) error { - return DB.QueryRowContext(ctx, ` - INSERT INTO notes (user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) - RETURNING id, created_at, updated_at`, - n.UserID, n.Title, n.Content, n.FolderPath, - pq.Array(n.Tags), ToJSON(n.Metadata), - models.NullString(n.SourceChannelID), models.NullString(n.SourceMessageID), - models.NullString(n.TeamID), - ).Scan(&n.ID, &n.CreatedAt, &n.UpdatedAt) -} - -func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) { - var n models.Note - var sourceChannelID, sourceMessageID, teamID sql.NullString - var metadataJSON []byte - err := DB.QueryRowContext(ctx, ` - SELECT id, user_id, title, content, folder_path, tags, metadata, - source_channel_id, source_message_id, team_id, created_at, updated_at - FROM notes WHERE id = $1`, id).Scan( - &n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath, - pq.Array(&n.Tags), &metadataJSON, - &sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt, - ) - if err != nil { - return nil, err - } - n.SourceChannelID = NullableStringPtr(sourceChannelID) - n.SourceMessageID = NullableStringPtr(sourceMessageID) - n.TeamID = NullableStringPtr(teamID) - json.Unmarshal(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, pq.Array(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 = $1", 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, source_message_id, team_id, created_at, updated_at", - "notes", - ).Where("user_id = ?", userID) - - if opts.FolderPath != "" { - b.Where("folder_path = ?", opts.FolderPath) - } - if opts.Tag != "" { - b.Where("? = ANY(tags)", 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() - - var result []models.Note - for rows.Next() { - var n models.Note - var sourceChannelID, sourceMessageID, teamID sql.NullString - var metadataJSON []byte - err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath, - pq.Array(&n.Tags), &metadataJSON, - &sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt) - if err != nil { - return nil, 0, err - } - n.SourceChannelID = NullableStringPtr(sourceChannelID) - n.SourceMessageID = NullableStringPtr(sourceMessageID) - n.TeamID = NullableStringPtr(teamID) - json.Unmarshal(metadataJSON, &n.Metadata) - result = append(result, n) - } - return result, total, rows.Err() -} - -func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Note, int, error) { - tsQuery := strings.Join(strings.Fields(query), " & ") - - b := NewSelect( - "id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at", - "notes", - ).Where("user_id = ?", userID).Where("search_vector @@ to_tsquery('english', ?)", tsQuery) - b.OrderBy("ts_rank(search_vector, to_tsquery('english', '"+tsQuery+"'))", "DESC") - b.Paginate(opts) - - var total int - DB.QueryRowContext(ctx, - "SELECT COUNT(*) FROM notes WHERE user_id = $1 AND search_vector @@ to_tsquery('english', $2)", - userID, tsQuery).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.Note - for rows.Next() { - var n models.Note - var sourceChannelID, sourceMessageID, teamID sql.NullString - var metadataJSON []byte - err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath, - pq.Array(&n.Tags), &metadataJSON, - &sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt) - if err != nil { - return nil, 0, err - } - n.SourceChannelID = NullableStringPtr(sourceChannelID) - n.SourceMessageID = NullableStringPtr(sourceMessageID) - n.TeamID = NullableStringPtr(teamID) - json.Unmarshal(metadataJSON, &n.Metadata) - result = append(result, n) - } - return result, total, rows.Err() -} - -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] = fmt.Sprintf("$%d", i+2) - args = append(args, id) - } - result, err := DB.ExecContext(ctx, - fmt.Sprintf("DELETE FROM notes WHERE user_id = $1 AND id IN (%s)", - strings.Join(placeholders, ",")), - args...) - if err != nil { - return 0, err - } - n, _ := result.RowsAffected() - return int(n), nil -} - -func (s *NoteStore) SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error) { - if limit <= 0 || limit > 50 { - limit = 10 - } - rows, err := DB.QueryContext(ctx, ` - SELECT id, title, folder_path - FROM notes - WHERE user_id = $1 AND title ILIKE '%' || $2 || '%' - ORDER BY updated_at DESC - LIMIT $3`, userID, query, limit) - if err != nil { - return nil, err - } - defer rows.Close() - - var results []models.Note - for rows.Next() { - var n models.Note - if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath); err != nil { - return nil, err - } - results = append(results, n) - } - return results, rows.Err() -} - -// ── CS5c additions (v0.29.0) ────────────────────────────────────────── - -func (s *NoteStore) SetEmbedding(ctx context.Context, noteID, vecStr string) error { - _, err := DB.ExecContext(ctx, - `UPDATE notes SET embedding = $1::vector WHERE id = $2`, - vecStr, noteID) - return err -} - -func (s *NoteStore) SearchKeyword(ctx context.Context, userID, query string, limit int) ([]store.NoteSearchResult, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, title, folder_path, tags, LEFT(content, 500), - ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank, - ts_headline('english', content, plainto_tsquery('english', $2), - 'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline - FROM notes - WHERE user_id = $1 - AND search_vector @@ plainto_tsquery('english', $2) - ORDER BY rank DESC - LIMIT $3 - `, userID, query, limit) - if err != nil { - return nil, err - } - defer rows.Close() - - results := make([]store.NoteSearchResult, 0) - for rows.Next() { - var r store.NoteSearchResult - var dbTags pq.StringArray - if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Excerpt, &r.Rank, &r.Headline); err != nil { - continue - } - r.Tags = []string(dbTags) - if r.Tags == nil { - r.Tags = []string{} - } - results = append(results, r) - } - return results, rows.Err() -} - -func (s *NoteStore) SearchSemantic(ctx context.Context, userID, vecStr string, limit int) ([]store.NoteSearchResult, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, title, folder_path, tags, LEFT(content, 500), - 1 - (embedding <=> $2::vector) AS similarity - FROM notes - WHERE user_id = $1 - AND embedding IS NOT NULL - AND 1 - (embedding <=> $2::vector) > 0.3 - ORDER BY embedding <=> $2::vector - LIMIT $3 - `, userID, vecStr, limit) - if err != nil { - return nil, err - } - defer rows.Close() - - results := make([]store.NoteSearchResult, 0) - for rows.Next() { - var r store.NoteSearchResult - var dbTags pq.StringArray - if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Excerpt, &r.Rank); err != nil { - continue - } - r.Tags = []string(dbTags) - if r.Tags == nil { - r.Tags = []string{} - } - results = append(results, r) - } - return results, rows.Err() -} - -func (s *NoteStore) ListFolders(ctx context.Context, userID string) ([]store.FolderInfo, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT DISTINCT folder_path, COUNT(*) AS count - FROM notes WHERE user_id = $1 - GROUP BY folder_path - ORDER BY folder_path - `, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - results := make([]store.FolderInfo, 0) - for rows.Next() { - var f store.FolderInfo - if err := rows.Scan(&f.Path, &f.Count); err != nil { - continue - } - results = append(results, f) - } - return results, rows.Err() -} diff --git a/server/store/postgres/note_link.go b/server/store/postgres/note_link.go deleted file mode 100644 index 1e45799..0000000 --- a/server/store/postgres/note_link.go +++ /dev/null @@ -1,179 +0,0 @@ -package postgres - -import ( - "context" - - "switchboard-core/models" - - "github.com/lib/pq" -) - -type NoteLinkStore struct{} - -func NewNoteLinkStore() *NoteLinkStore { return &NoteLinkStore{} } - -func (s *NoteLinkStore) ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error { - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - // Delete existing links for this source note - if _, err := tx.ExecContext(ctx, - "DELETE FROM note_links WHERE source_note_id = $1", sourceNoteID); err != nil { - return err - } - - // Insert new links - if len(links) > 0 { - stmt, err := tx.PrepareContext(ctx, ` - INSERT INTO note_links (source_note_id, target_note_id, target_title, display_text, is_transclusion) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (source_note_id, target_title) DO NOTHING`) - if err != nil { - return err - } - defer stmt.Close() - - for _, l := range links { - var targetID interface{} - if l.TargetNoteID != nil { - targetID = *l.TargetNoteID - } - if _, err := stmt.ExecContext(ctx, - sourceNoteID, targetID, l.TargetTitle, l.DisplayText, l.IsTransclusion, - ); err != nil { - return err - } - } - } - - return tx.Commit() -} - -func (s *NoteLinkStore) ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error { - _, err := DB.ExecContext(ctx, ` - UPDATE note_links - SET target_note_id = $1 - WHERE target_note_id IS NULL - AND LOWER(target_title) = LOWER($2) - AND source_note_id IN (SELECT id FROM notes WHERE user_id = $3)`, - targetNoteID, title, userID) - return err -} - -func (s *NoteLinkStore) Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT n.id, n.title, n.folder_path, n.updated_at, COALESCE(nl.display_text, '') - FROM note_links nl - JOIN notes n ON n.id = nl.source_note_id - WHERE nl.target_note_id = $1 - ORDER BY n.updated_at DESC`, noteID) - if err != nil { - return nil, err - } - defer rows.Close() - - var results []models.NoteLinkResult - for rows.Next() { - var r models.NoteLinkResult - if err := rows.Scan(&r.SourceNoteID, &r.Title, &r.FolderPath, - &r.UpdatedAt, &r.DisplayText); err != nil { - return nil, err - } - results = append(results, r) - } - return results, rows.Err() -} - -func (s *NoteLinkStore) Graph(ctx context.Context, userID string) (*models.NoteGraph, error) { - graph := &models.NoteGraph{ - Nodes: make([]models.NoteGraphNode, 0), - Edges: make([]models.NoteGraphEdge, 0), - Unresolved: make([]models.NoteGraphDangling, 0), - } - - // Nodes: all user's notes with link counts - nodeRows, err := DB.QueryContext(ctx, ` - SELECT n.id, n.title, n.folder_path, n.tags, n.updated_at::text, - COALESCE(outbound.cnt, 0) + COALESCE(inbound.cnt, 0) AS link_count - FROM notes n - LEFT JOIN ( - SELECT source_note_id, COUNT(*) AS cnt FROM note_links - WHERE target_note_id IS NOT NULL GROUP BY source_note_id - ) outbound ON outbound.source_note_id = n.id - LEFT JOIN ( - SELECT target_note_id, COUNT(*) AS cnt FROM note_links - WHERE target_note_id IS NOT NULL GROUP BY target_note_id - ) inbound ON inbound.target_note_id = n.id - WHERE n.user_id = $1 - ORDER BY n.updated_at DESC`, userID) - if err != nil { - return nil, err - } - defer nodeRows.Close() - - for nodeRows.Next() { - var node models.NoteGraphNode - var tags []string - if err := nodeRows.Scan(&node.ID, &node.Title, &node.FolderPath, - pq.Array(&tags), &node.UpdatedAt, &node.LinkCount); err != nil { - return nil, err - } - if tags == nil { - tags = []string{} - } - node.Tags = tags - graph.Nodes = append(graph.Nodes, node) - } - if err := nodeRows.Err(); err != nil { - return nil, err - } - - // Resolved edges - edgeRows, err := DB.QueryContext(ctx, ` - SELECT nl.source_note_id, nl.target_note_id, nl.target_title, nl.is_transclusion - FROM note_links nl - JOIN notes n ON n.id = nl.source_note_id - WHERE n.user_id = $1 - AND nl.target_note_id IS NOT NULL`, userID) - if err != nil { - return nil, err - } - defer edgeRows.Close() - - for edgeRows.Next() { - var edge models.NoteGraphEdge - if err := edgeRows.Scan(&edge.Source, &edge.Target, - &edge.Title, &edge.IsTransclusion); err != nil { - return nil, err - } - graph.Edges = append(graph.Edges, edge) - } - if err := edgeRows.Err(); err != nil { - return nil, err - } - - // Unresolved links (dangling) - danglingRows, err := DB.QueryContext(ctx, ` - SELECT nl.source_note_id, nl.target_title - FROM note_links nl - JOIN notes n ON n.id = nl.source_note_id - WHERE n.user_id = $1 - AND nl.target_note_id IS NULL`, userID) - if err != nil { - return nil, err - } - defer danglingRows.Close() - - for danglingRows.Next() { - var d models.NoteGraphDangling - if err := danglingRows.Scan(&d.Source, &d.Title); err != nil { - return nil, err - } - graph.Unresolved = append(graph.Unresolved, d) - } - - return graph, danglingRows.Err() -} diff --git a/server/store/postgres/persona.go b/server/store/postgres/persona.go deleted file mode 100644 index 4d33a8b..0000000 --- a/server/store/postgres/persona.go +++ /dev/null @@ -1,418 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - - "switchboard-core/models" -) - -type PersonaStore struct{} - -func NewPersonaStore() *PersonaStore { return &PersonaStore{} } - -const personaCols = `id, name, handle, 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, memory_enabled, memory_extraction_prompt, - created_at, updated_at` - -func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error { - // Auto-generate handle from name if not set - if p.Handle == "" { - p.Handle = models.HandleFromName(p.Name) - } - return DB.QueryRowContext(ctx, ` - INSERT INTO personas (name, handle, 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, - memory_enabled, memory_extraction_prompt) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) - RETURNING id, created_at, updated_at`, - p.Name, p.Handle, 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, - p.MemoryEnabled, p.MemoryExtractionPrompt, - ).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) -} - -func (s *PersonaStore) GetByID(ctx context.Context, id string) (*models.Persona, error) { - row := DB.QueryRowContext(ctx, - fmt.Sprintf("SELECT %s FROM personas WHERE id = $1", 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.Handle != nil { - b.Set("handle", *patch.Handle) - } - 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 patch.MemoryEnabled != nil { - b.Set("memory_enabled", *patch.MemoryEnabled) - } - if patch.MemoryExtractionPrompt != nil { - b.Set("memory_extraction_prompt", *patch.MemoryExtractionPrompt) - } - 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 = $1", 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 = true AND ( - scope = 'global' - OR (scope = 'personal' AND created_by = $1) - OR (scope = 'team' AND owner_id IN ( - SELECT team_id FROM team_members WHERE user_id = $1 - )) - OR (scope = 'personal' AND is_shared = true) - 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 - WHERE gm.user_id = $1 - AND gm.group_id = ANY(rg.granted_groups) - )) - ) - ) - ) ORDER BY scope, name`, personaCols), 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 = $1 AND is_active = true 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) -} - -// ── 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 = $1", personaID) - if err != nil { - return err - } - - for _, g := range grants { - configJSON := ToJSON(g.Config) - _, err = tx.ExecContext(ctx, ` - INSERT INTO persona_grants (persona_id, grant_type, grant_ref, config) - VALUES ($1, $2, $3, $4)`, - 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 = $1 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, &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 = $1 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 = $2 AND is_active = true AND ( - scope = 'global' - OR (scope = 'personal' AND created_by = $1) - OR (scope = 'team' AND owner_id IN ( - SELECT team_id FROM team_members WHERE user_id = $1 - )) - OR (scope = 'personal' AND is_shared = true) - OR id IN ( - SELECT rg.resource_id FROM resource_grants rg - WHERE rg.resource_type = 'persona' - AND rg.resource_id = $2 - AND ( - rg.grant_scope = 'global' - OR (rg.grant_scope = 'groups' - AND EXISTS ( - SELECT 1 FROM group_members gm - WHERE gm.user_id = $1 - AND gm.group_id = ANY(rg.granted_groups) - )) - ) - ) - ) - )`, userID, personaID).Scan(&exists) - return exists, err -} - -// ── Scanners ──────────────────────────────── - -func scanPersona(row *sql.Row) (*models.Persona, error) { - var p models.Persona - var providerConfigID, ownerID sql.NullString - var handle sql.NullString - var temp, topP sql.NullFloat64 - var maxTokens, thinkingBudget sql.NullInt64 - err := row.Scan( - &p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar, - &p.BaseModelID, &providerConfigID, - &p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP, - &p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared, - &p.MemoryEnabled, &p.MemoryExtractionPrompt, - &p.CreatedAt, &p.UpdatedAt, - ) - if err != nil { - return nil, err - } - if handle.Valid { - p.Handle = handle.String - } - 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 handle sql.NullString - var temp, topP sql.NullFloat64 - var maxTokens, thinkingBudget sql.NullInt64 - err := rows.Scan( - &p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar, - &p.BaseModelID, &providerConfigID, - &p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP, - &p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared, - &p.MemoryEnabled, &p.MemoryExtractionPrompt, - &p.CreatedAt, &p.UpdatedAt, - ) - if err != nil { - return nil, err - } - if handle.Valid { - p.Handle = handle.String - } - 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 = $1", 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 ($1, $2, $3)`, - 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 = $1 - 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, &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 = $1", 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() -} diff --git a/server/store/postgres/persona_group.go b/server/store/postgres/persona_group.go deleted file mode 100644 index 5311def..0000000 --- a/server/store/postgres/persona_group.go +++ /dev/null @@ -1,138 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - - "switchboard-core/models" -) - -type PersonaGroupStore struct{} - -func NewPersonaGroupStore() *PersonaGroupStore { return &PersonaGroupStore{} } - -const personaGroupCols = `id, name, description, owner_id, scope, team_id, created_at, updated_at` - -func (s *PersonaGroupStore) List(ctx context.Context, ownerID string) ([]models.PersonaGroup, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT `+personaGroupCols+` FROM persona_groups - WHERE owner_id = $1 ORDER BY name - `, ownerID) - if err != nil { - return nil, err - } - defer rows.Close() - - groups := []models.PersonaGroup{} - for rows.Next() { - var g models.PersonaGroup - if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, - &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt); err != nil { - continue - } - groups = append(groups, g) - } - return groups, rows.Err() -} - -func (s *PersonaGroupStore) Get(ctx context.Context, id, ownerID string) (*models.PersonaGroup, error) { - var g models.PersonaGroup - err := DB.QueryRowContext(ctx, ` - SELECT `+personaGroupCols+` FROM persona_groups WHERE id = $1 AND owner_id = $2 - `, id, ownerID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, - &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &g, nil -} - -func (s *PersonaGroupStore) Create(ctx context.Context, g *models.PersonaGroup) error { - return DB.QueryRowContext(ctx, ` - INSERT INTO persona_groups (name, description, owner_id, scope) - VALUES ($1, $2, $3, $4) - RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at - `, g.Name, g.Description, g.OwnerID, g.Scope).Scan( - &g.ID, &g.Name, &g.Description, &g.OwnerID, - &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt) -} - -func (s *PersonaGroupStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { - for k, v := range fields { - _, err := DB.ExecContext(ctx, - `UPDATE persona_groups SET `+k+` = $1, updated_at = NOW() WHERE id = $2`, v, id) - if err != nil { - return err - } - } - return nil -} - -func (s *PersonaGroupStore) Delete(ctx context.Context, id, ownerID string) (int64, error) { - result, err := DB.ExecContext(ctx, - `DELETE FROM persona_groups WHERE id = $1 AND owner_id = $2`, id, ownerID) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -func (s *PersonaGroupStore) GetOwnerID(ctx context.Context, id string) (string, error) { - var ownerID string - err := DB.QueryRowContext(ctx, - `SELECT owner_id FROM persona_groups WHERE id = $1`, id).Scan(&ownerID) - if err == sql.ErrNoRows { - return "", nil - } - return ownerID, err -} - -func (s *PersonaGroupStore) AddMember(ctx context.Context, groupID, personaID string, isLeader bool) error { - if isLeader { - _, _ = DB.ExecContext(ctx, - `UPDATE persona_group_members SET is_leader = false WHERE group_id = $1`, groupID) - } - _, err := DB.ExecContext(ctx, ` - INSERT INTO persona_group_members (group_id, persona_id, is_leader) - VALUES ($1, $2, $3) - ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = EXCLUDED.is_leader - `, groupID, personaID, isLeader) - return err -} - -func (s *PersonaGroupStore) RemoveMember(ctx context.Context, memberID, groupID string) error { - _, err := DB.ExecContext(ctx, - `DELETE FROM persona_group_members WHERE id = $1 AND group_id = $2`, memberID, groupID) - return err -} - -func (s *PersonaGroupStore) ListMembers(ctx context.Context, groupID string) ([]models.PersonaGroupMember, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order, - COALESCE(p.name, '') AS persona_name, - COALESCE(p.handle, '') AS persona_handle, - COALESCE(p.avatar, '') AS persona_avatar - FROM persona_group_members pgm - LEFT JOIN personas p ON p.id = pgm.persona_id - WHERE pgm.group_id = $1 - ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id - `, groupID) - if err != nil { - return []models.PersonaGroupMember{}, nil - } - defer rows.Close() - - members := []models.PersonaGroupMember{} - for rows.Next() { - var m models.PersonaGroupMember - if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder, - &m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil { - continue - } - members = append(members, m) - } - return members, rows.Err() -} diff --git a/server/store/postgres/persona_mention.go b/server/store/postgres/persona_mention.go deleted file mode 100644 index 514e84f..0000000 --- a/server/store/postgres/persona_mention.go +++ /dev/null @@ -1,80 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - - "switchboard-core/store" -) - -// ── Mention resolution + display info (v0.29.0) ──────────────────────── - -func (s *PersonaStore) FindActiveByHandle(ctx context.Context, handle string) (string, error) { - var id string - err := DB.QueryRowContext(ctx, ` - SELECT id FROM personas - WHERE LOWER(handle) = LOWER($1) AND is_active = true - LIMIT 1 - `, handle).Scan(&id) - if err == sql.ErrNoRows { - return "", nil - } - return id, err -} - -func (s *PersonaStore) FindActiveByHandlePrefix(ctx context.Context, prefix string) (string, int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE LOWER($1) AND is_active = true - `, prefix+"%").Scan(&count) - if err != nil { - return "", 0, err - } - if count != 1 { - return "", count, nil - } - var id string - err = DB.QueryRowContext(ctx, ` - SELECT id FROM personas WHERE LOWER(handle) LIKE LOWER($1) AND is_active = true - `, prefix+"%").Scan(&id) - return id, 1, err -} - -func (s *PersonaStore) GetNameByID(ctx context.Context, id string) (string, error) { - var name string - err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = $1`, id).Scan(&name) - if err == sql.ErrNoRows { - return "", nil - } - return name, err -} - -func (s *PersonaStore) GetNamesByIDs(ctx context.Context, ids []string) (map[string]string, error) { - result := make(map[string]string) - for _, id := range ids { - var name string - err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = $1`, id).Scan(&name) - if err == nil && name != "" { - result[id] = name - } - } - return result, nil -} - -func (s *PersonaStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) { - result := make(map[string]store.UserDisplayInfo) - for _, id := range ids { - var name, avatar sql.NullString - _ = DB.QueryRowContext(ctx, ` - SELECT name, avatar FROM personas WHERE id = $1 - `, id).Scan(&name, &avatar) - if name.Valid { - info := store.UserDisplayInfo{Name: name.String} - if avatar.Valid { - info.Avatar = avatar.String - } - result[id] = info - } - } - return result, nil -} diff --git a/server/store/postgres/policy.go b/server/store/postgres/policy.go deleted file mode 100644 index 7a65890..0000000 --- a/server/store/postgres/policy.go +++ /dev/null @@ -1,68 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - - "switchboard-core/models" -) - -type PolicyStore struct{} - -func NewPolicyStore() *PolicyStore { return &PolicyStore{} } - -// Get returns a policy value by key. Falls back to PolicyDefaults if not in DB. -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 = $1", key).Scan(&value) - if err == sql.ErrNoRows { - if def, ok := models.PolicyDefaults[key]; ok { - return def, nil - } - return "", nil - } - return value, err -} - -// GetBool returns a policy value as a boolean. -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 -} - -// Set upserts a policy value. -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 ($1, $2, $3, NOW()) - ON CONFLICT (key) DO UPDATE SET value = $2, updated_by = $3, updated_at = NOW()`, - key, value, updatedBy) - return err -} - -// GetAll returns all platform policies, merged with defaults. -func (s *PolicyStore) GetAll(ctx context.Context) (map[string]string, error) { - result := make(map[string]string) - // Start with defaults - for k, v := range models.PolicyDefaults { - result[k] = v - } - // Override with DB values - rows, err := DB.QueryContext(ctx, "SELECT key, value FROM platform_policies") - if err != nil { - return result, err // return defaults on error - } - 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() -} diff --git a/server/store/postgres/pricing.go b/server/store/postgres/pricing.go deleted file mode 100644 index 512a6cf..0000000 --- a/server/store/postgres/pricing.go +++ /dev/null @@ -1,131 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - - "switchboard-core/models" -) - -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 = $1 AND model_id = $2 - `, providerConfigID, modelID).Scan( - &p.ID, &p.ProviderConfigID, &p.ModelID, - &p.InputPerM, &p.OutputPerM, &p.CacheCreatePerM, &p.CacheReadPerM, - &p.Currency, &p.Source, &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 ( - 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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, 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 = NOW() - `, - 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 ( - provider_config_id, model_id, - input_per_m, output_per_m, - currency, source, updated_at - ) VALUES ($1, $2, $3, $4, $5, 'catalog', 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 = NOW() - WHERE model_pricing.source = 'catalog' - `, - 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, &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 = $1 AND model_id = $2 - `, providerConfigID, modelID) - return err -} diff --git a/server/store/postgres/project.go b/server/store/postgres/project.go deleted file mode 100644 index 84eb951..0000000 --- a/server/store/postgres/project.go +++ /dev/null @@ -1,464 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "fmt" - - "github.com/lib/pq" - - "switchboard-core/models" - "switchboard-core/store" -) - -// ── ProjectStore ─────────────────────────── - -type ProjectStore struct{} - -func NewProjectStore() *ProjectStore { return &ProjectStore{} } - -// ── CRUD ──────────────────────────────────── - -func (s *ProjectStore) Create(ctx context.Context, p *models.Project) error { - return DB.QueryRowContext(ctx, ` - INSERT INTO projects (name, description, color, icon, scope, owner_id, team_id, settings) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, created_at, updated_at`, - p.Name, p.Description, p.Color, p.Icon, p.Scope, - p.OwnerID, models.NullString(p.TeamID), ToJSON(p.Settings), - ).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) -} - -func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, error) { - var p models.Project - var teamID, workspaceID sql.NullString - err := DB.QueryRowContext(ctx, ` - SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, - p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings, - p.created_at, p.updated_at, - (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_notes WHERE project_id = p.id) - FROM projects p - WHERE p.id = $1`, id).Scan( - &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, - &p.OwnerID, &teamID, &p.IsArchived, &workspaceID, &p.Settings, - &p.CreatedAt, &p.UpdatedAt, - &p.ChannelCount, &p.KBCount, &p.NoteCount, - ) - if err != nil { - return nil, err - } - p.TeamID = NullableStringPtr(teamID) - p.WorkspaceID = NullableStringPtr(workspaceID) - return &p, nil -} - -func (s *ProjectStore) Update(ctx context.Context, id string, patch models.ProjectPatch) error { - b := NewUpdate("projects") - if patch.Name != nil { - b.Set("name", *patch.Name) - } - if patch.Description != nil { - b.Set("description", *patch.Description) - } - if patch.Color != nil { - b.Set("color", *patch.Color) - } - if patch.Icon != nil { - b.Set("icon", *patch.Icon) - } - if patch.IsArchived != nil { - b.Set("is_archived", *patch.IsArchived) - } - if patch.WorkspaceID != nil { - b.Set("workspace_id", models.NullString(patch.WorkspaceID)) - } - if len(patch.Settings) > 0 { - // Merge: read existing settings, overlay with patch values, write back. - var existing models.JSONMap - _ = DB.QueryRowContext(ctx, "SELECT settings FROM projects WHERE id = $1", id).Scan(&existing) - if existing == nil { - existing = make(models.JSONMap) - } - for k, v := range patch.Settings { - existing[k] = v - } - b.Set("settings", ToJSON(existing)) - } - 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 *ProjectStore) Delete(ctx context.Context, id string) error { - // CASCADE handles junction tables. - // Channels get project_id set to NULL via ON DELETE SET NULL. - res, err := DB.ExecContext(ctx, "DELETE FROM projects WHERE id = $1", id) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return sql.ErrNoRows - } - return nil -} - -// ── Listing ───────────────────────────────── - -func (s *ProjectStore) ListForUser(ctx context.Context, userID string, teamIDs []string, includeArchived bool) ([]models.Project, error) { - // User sees: their personal projects + team projects for their teams + global projects - q := ` - SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, - p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings, - p.created_at, p.updated_at, - (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_notes WHERE project_id = p.id) - FROM projects p - WHERE ( - (p.scope = 'personal' AND p.owner_id = $1) - OR p.scope = 'global'` - - args := []interface{}{userID} - - if len(teamIDs) > 0 { - q += fmt.Sprintf(` - OR (p.scope = 'team' AND p.team_id = ANY($%d))`, len(args)+1) - args = append(args, pq.Array(teamIDs)) - } - q += `)` - - if !includeArchived { - q += ` AND p.is_archived = false` - } - q += ` ORDER BY p.name` - - return queryProjects(ctx, q, args...) -} - -// ── Channel Association ───────────────────── - -func (s *ProjectStore) AddChannel(ctx context.Context, projectID, channelID string, position int) error { - // Atomic move: remove from old project (if any), add to new. - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - // Remove existing association (handles the "move" case) - tx.ExecContext(ctx, `DELETE FROM project_channels WHERE channel_id = $1`, channelID) - - // Insert new association - _, err = tx.ExecContext(ctx, ` - INSERT INTO project_channels (project_id, channel_id, position) - VALUES ($1, $2, $3)`, - projectID, channelID, position) - if err != nil { - return err - } - - // Update denormalized column - _, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = $1 WHERE id = $2`, - projectID, channelID) - if err != nil { - return err - } - - return tx.Commit() -} - -func (s *ProjectStore) RemoveChannel(ctx context.Context, projectID, channelID string) error { - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - res, err := tx.ExecContext(ctx, ` - DELETE FROM project_channels WHERE project_id = $1 AND channel_id = $2`, - projectID, channelID) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return sql.ErrNoRows - } - - // Clear denormalized column - _, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = NULL WHERE id = $1`, channelID) - if err != nil { - return err - } - - return tx.Commit() -} - -func (s *ProjectStore) ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT project_id, channel_id, position, COALESCE(folder, ''), added_at - FROM project_channels - WHERE project_id = $1 - ORDER BY position, added_at`, projectID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.ProjectChannel - for rows.Next() { - var pc models.ProjectChannel - if err := rows.Scan(&pc.ProjectID, &pc.ChannelID, &pc.Position, &pc.Folder, &pc.AddedAt); err != nil { - return nil, err - } - result = append(result, pc) - } - return result, rows.Err() -} - -func (s *ProjectStore) ReorderChannels(ctx context.Context, projectID string, channelIDs []string) error { - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - for i, chID := range channelIDs { - _, err := tx.ExecContext(ctx, ` - UPDATE project_channels SET position = $1 - WHERE project_id = $2 AND channel_id = $3`, - i, projectID, chID) - if err != nil { - return err - } - } - return tx.Commit() -} - -// ── KB Association ────────────────────────── - -func (s *ProjectStore) AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO project_knowledge_bases (project_id, kb_id, auto_search) - VALUES ($1, $2, $3) - ON CONFLICT (project_id, kb_id) DO UPDATE SET auto_search = EXCLUDED.auto_search`, - projectID, kbID, autoSearch) - return err -} - -func (s *ProjectStore) RemoveKB(ctx context.Context, projectID, kbID string) error { - res, err := DB.ExecContext(ctx, ` - DELETE FROM project_knowledge_bases WHERE project_id = $1 AND kb_id = $2`, - projectID, kbID) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return sql.ErrNoRows - } - return nil -} - -func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT pk.project_id, pk.kb_id, pk.auto_search, pk.added_at, - COALESCE(kb.name, '') AS name - FROM project_knowledge_bases pk - LEFT JOIN knowledge_bases kb ON kb.id = pk.kb_id - WHERE pk.project_id = $1 - ORDER BY pk.added_at`, projectID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.ProjectKB - for rows.Next() { - var pkb models.ProjectKB - if err := rows.Scan(&pkb.ProjectID, &pkb.KBID, &pkb.AutoSearch, &pkb.AddedAt, &pkb.Name); err != nil { - return nil, err - } - result = append(result, pkb) - } - return result, rows.Err() -} - -func (s *ProjectStore) GetKBIDs(ctx context.Context, projectID string) ([]string, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT kb_id FROM project_knowledge_bases - WHERE project_id = $1`, projectID) - 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() -} - -// ── Note Association ──────────────────────── - -func (s *ProjectStore) AddNote(ctx context.Context, projectID, noteID string) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO project_notes (project_id, note_id) - VALUES ($1, $2) - ON CONFLICT DO NOTHING`, - projectID, noteID) - return err -} - -func (s *ProjectStore) RemoveNote(ctx context.Context, projectID, noteID string) error { - res, err := DB.ExecContext(ctx, ` - DELETE FROM project_notes WHERE project_id = $1 AND note_id = $2`, - projectID, noteID) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return sql.ErrNoRows - } - return nil -} - -func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT pn.project_id, pn.note_id, pn.added_at, - COALESCE(n.title, '') AS title - FROM project_notes pn - LEFT JOIN notes n ON n.id = pn.note_id - WHERE pn.project_id = $1 - ORDER BY pn.added_at`, projectID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.ProjectNote - for rows.Next() { - var pn models.ProjectNote - if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt, &pn.Title); err != nil { - return nil, err - } - result = append(result, pn) - } - return result, rows.Err() -} - -// ── Access Check ──────────────────────────── - -func (s *ProjectStore) UserCanAccess(ctx context.Context, userID, projectID string, teamIDs []string) (bool, error) { - q := ` - SELECT EXISTS( - SELECT 1 FROM projects p - WHERE p.id = $1 AND ( - (p.scope = 'personal' AND p.owner_id = $2) - OR p.scope = 'global'` - - args := []interface{}{projectID, userID} - - if len(teamIDs) > 0 { - q += fmt.Sprintf(` - OR (p.scope = 'team' AND p.team_id = ANY($%d))`, len(args)+1) - args = append(args, pq.Array(teamIDs)) - } - q += `))` - - var ok bool - err := DB.QueryRowContext(ctx, q, args...).Scan(&ok) - return ok, err -} - -func (s *ProjectStore) GetProjectIDForChannel(ctx context.Context, channelID string) (string, error) { - var projectID string - err := DB.QueryRowContext(ctx, ` - SELECT project_id FROM project_channels WHERE channel_id = $1`, - channelID).Scan(&projectID) - if err == sql.ErrNoRows { - return "", nil // no project — not an error - } - return projectID, err -} - -// ── CS5c additions (v0.29.0) ────────────────────────────────────────── - -func (s *ProjectStore) AdminList(ctx context.Context, includeArchived bool) ([]store.AdminProject, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT p.id, p.name, p.description, p.scope, - p.owner_id, p.team_id, p.is_archived, - p.created_at, p.updated_at, - (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_notes WHERE project_id = p.id), - COALESCE(u.username, '') - FROM projects p - LEFT JOIN users u ON u.id = p.owner_id - WHERE ($1 OR p.is_archived = false) - ORDER BY p.updated_at DESC`, includeArchived) - if err != nil { - return nil, err - } - defer rows.Close() - - results := make([]store.AdminProject, 0) - for rows.Next() { - var p store.AdminProject - var teamID sql.NullString - if err := rows.Scan( - &p.ID, &p.Name, &p.Description, &p.Scope, - &p.OwnerID, &teamID, &p.IsArchived, - &p.CreatedAt, &p.UpdatedAt, - &p.ChannelCount, &p.KBCount, &p.NoteCount, - &p.OwnerName, - ); err != nil { - return nil, err - } - p.TeamID = NullableStringPtr(teamID) - results = append(results, p) - } - return results, rows.Err() -} - -// ── Query Helper ──────────────────────────── - -func queryProjects(ctx context.Context, q string, args ...interface{}) ([]models.Project, error) { - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, fmt.Errorf("queryProjects: %w", err) - } - defer rows.Close() - - var result []models.Project - for rows.Next() { - var p models.Project - var teamID, workspaceID sql.NullString - if err := rows.Scan( - &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, - &p.OwnerID, &teamID, &p.IsArchived, &workspaceID, &p.Settings, - &p.CreatedAt, &p.UpdatedAt, - &p.ChannelCount, &p.KBCount, &p.NoteCount, - ); err != nil { - return nil, err - } - p.TeamID = NullableStringPtr(teamID) - p.WorkspaceID = NullableStringPtr(workspaceID) - result = append(result, p) - } - return result, rows.Err() -} diff --git a/server/store/postgres/provider.go b/server/store/postgres/provider.go deleted file mode 100644 index 26fc59e..0000000 --- a/server/store/postgres/provider.go +++ /dev/null @@ -1,258 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - - "switchboard-core/models" -) - -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 { - return DB.QueryRowContext(ctx, ` - INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc, - key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) - RETURNING id, created_at, updated_at`, - 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, - ).Scan(&cfg.ID, &cfg.CreatedAt, &cfg.UpdatedAt) -} - -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 = $1", 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 = $1", 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 = true AND ( - scope = 'global' - OR (scope = 'personal' AND owner_id = $1) - OR (scope = 'team' AND owner_id IN ( - SELECT team_id FROM team_members WHERE user_id = $1 - )) - ) - ORDER BY scope, name`, providerCols), 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 = $2 AND is_active = true AND ( - scope = 'global' - OR (scope = 'personal' AND owner_id = $1) - OR (scope = 'team' AND owner_id IN ( - SELECT team_id FROM team_members WHERE user_id = $1 - )) - ) - )`, userID, configID).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 = $1 AND is_active = true ORDER BY name", providerCols), - scope) - } else { - rows, err = DB.QueryContext(ctx, - fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = $1 AND owner_id = $2 AND is_active = true 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, &p.CreatedAt, &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, &p.CreatedAt, &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() -} - -// ── CS4 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *ProviderStore) DeletePersonalByOwner(ctx context.Context, ownerID string) (int64, error) { - result, err := DB.ExecContext(ctx, - `DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1`, ownerID) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -// ── CS6 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *ProviderStore) ListAllForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) { - rows, err := DB.QueryContext(ctx, - fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = 'team' AND owner_id = $1 ORDER BY name", providerCols), - teamID) - if err != nil { - return nil, err - } - defer rows.Close() - return scanProviders(rows) -} - -func (s *ProviderStore) DeleteByIDAndTeam(ctx context.Context, id, teamID string) (int64, error) { - res, err := DB.ExecContext(ctx, - `DELETE FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2`, - id, teamID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -// ── CS7a additions (v0.29.0) ──────────────────────────────────────────── - -func (s *ProviderStore) FindFirstForUser(ctx context.Context, userID string) (string, error) { - var configID string - err := DB.QueryRowContext(ctx, ` - SELECT id FROM provider_configs - WHERE is_active = true AND ( - (scope = 'personal' AND owner_id = $1) - OR scope = 'global' - ) - ORDER BY scope ASC, created_at ASC - LIMIT 1 - `, userID).Scan(&configID) - return configID, err -} - -func (s *ProviderStore) LoadAccessible(ctx context.Context, configID, userID string) (*models.ProviderConfig, error) { - row := DB.QueryRowContext(ctx, fmt.Sprintf(` - SELECT %s FROM provider_configs - WHERE id = $1 AND is_active = true - AND (scope = 'global' - OR (scope = 'personal' AND owner_id = $2) - OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2))) - `, providerCols), configID, userID) - return scanProvider(row) -} diff --git a/server/store/postgres/routing_policy.go b/server/store/postgres/routing_policy.go deleted file mode 100644 index 04c813a..0000000 --- a/server/store/postgres/routing_policy.go +++ /dev/null @@ -1,110 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "encoding/json" - - "switchboard-core/models" -) - -// RoutingPolicyStore implements store.RoutingPolicyStore for Postgres. -type RoutingPolicyStore struct { - db *sql.DB -} - -func NewRoutingPolicyStore(db *sql.DB) *RoutingPolicyStore { - return &RoutingPolicyStore{db: db} -} - -func (s *RoutingPolicyStore) Create(ctx context.Context, p *models.RoutingPolicy) error { - configJSON, err := json.Marshal(p.Config) - if err != nil { - return err - } - return s.db.QueryRowContext(ctx, ` - INSERT INTO routing_policies (name, scope, team_id, priority, policy_type, config, is_active) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, created_at, updated_at - `, p.Name, p.Scope, p.TeamID, p.Priority, p.Type, configJSON, p.IsActive, - ).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) -} - -func (s *RoutingPolicyStore) Update(ctx context.Context, p *models.RoutingPolicy) error { - configJSON, err := json.Marshal(p.Config) - if err != nil { - return err - } - _, err = s.db.ExecContext(ctx, ` - UPDATE routing_policies - SET name = $2, scope = $3, team_id = $4, priority = $5, - policy_type = $6, config = $7, is_active = $8 - WHERE id = $1 - `, p.ID, p.Name, p.Scope, p.TeamID, p.Priority, p.Type, configJSON, p.IsActive) - return err -} - -func (s *RoutingPolicyStore) Delete(ctx context.Context, id string) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM routing_policies WHERE id = $1`, id) - return err -} - -func (s *RoutingPolicyStore) GetByID(ctx context.Context, id string) (*models.RoutingPolicy, error) { - var p models.RoutingPolicy - var configJSON []byte - err := s.db.QueryRowContext(ctx, ` - SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at - FROM routing_policies WHERE id = $1 - `, id).Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &p.IsActive, &p.CreatedAt, &p.UpdatedAt) - if err != nil { - return nil, err - } - json.Unmarshal(configJSON, &p.Config) - return &p, nil -} - -func (s *RoutingPolicyStore) ListActive(ctx context.Context) ([]models.RoutingPolicy, error) { - return s.query(ctx, ` - SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at - FROM routing_policies - WHERE is_active = true - ORDER BY priority ASC, created_at ASC - `) -} - -func (s *RoutingPolicyStore) ListAll(ctx context.Context) ([]models.RoutingPolicy, error) { - return s.query(ctx, ` - SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at - FROM routing_policies - ORDER BY priority ASC, created_at ASC - `) -} - -func (s *RoutingPolicyStore) ListForTeam(ctx context.Context, teamID string) ([]models.RoutingPolicy, error) { - return s.query(ctx, ` - SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at - FROM routing_policies - WHERE is_active = true AND (scope = 'global' OR (scope = 'team' AND team_id = $1)) - ORDER BY priority ASC, created_at ASC - `, teamID) -} - -func (s *RoutingPolicyStore) query(ctx context.Context, q string, args ...interface{}) ([]models.RoutingPolicy, error) { - rows, err := s.db.QueryContext(ctx, q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.RoutingPolicy - for rows.Next() { - var p models.RoutingPolicy - var configJSON []byte - if err := rows.Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &p.IsActive, &p.CreatedAt, &p.UpdatedAt); err != nil { - return nil, err - } - json.Unmarshal(configJSON, &p.Config) - result = append(result, p) - } - return result, rows.Err() -} diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index d459db3..5e2142b 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -11,47 +11,25 @@ import ( 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(), - NoteLinks: NewNoteLinkStore(), GlobalConfig: NewGlobalConfigStore(), - Usage: NewUsageStore(), - Pricing: NewPricingStore(), - Files: NewFileStore(), - KnowledgeBases: NewKnowledgeBaseStore(), Groups: NewGroupStore(), ResourceGrants: NewResourceGrantStore(), - Memories: NewMemoryStore(), - Projects: NewProjectStore(), Notifications: NewNotificationStore(), NotifPrefs: NewNotificationPreferenceStore(), - Workspaces: NewWorkspaceStore(), - GitCredentials: &GitCredentialStore{}, - CapOverrides: NewCapOverrideStore(db), - RoutingPolicies: NewRoutingPolicyStore(db), - Sessions: NewSessionStore(), - Packages: NewPackageStore(), - Workflows: NewWorkflowStore(), - Tasks: NewTaskStore(), - Presence: NewPresenceStore(), - PersonaGroups: NewPersonaGroupStore(), - Folders: NewFolderStore(), - Health: NewHealthStore(db), - ExtPermissions: NewExtensionPermissionStore(db), - ExtData: NewExtDataStore(db), - Tickets: NewTicketStore(), - RateLimits: NewRateLimitStore(), - Export: NewExportStore(), - Connections: NewConnectionStore(), - Dependencies: NewDependencyStore(), + Sessions: NewSessionStore(), + Presence: NewPresenceStore(), + Health: NewHealthStore(db), + Connections: NewConnectionStore(), + Dependencies: NewDependencyStore(), + Packages: NewPackageStore(), + Workflows: NewWorkflowStore(), + Tasks: NewTaskStore(), + ExtPermissions: NewExtensionPermissionStore(db), + ExtData: NewExtDataStore(db), + Tickets: NewTicketStore(), + RateLimits: NewRateLimitStore(), } } diff --git a/server/store/postgres/usage.go b/server/store/postgres/usage.go deleted file mode 100644 index e17be07..0000000 --- a/server/store/postgres/usage.go +++ /dev/null @@ -1,246 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - "strings" - "time" - - "switchboard-core/models" - "switchboard-core/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 ( - 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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - `, - 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 = $1"} - 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 = $1", - "provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = $1)", - } - args := []interface{}{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 = $1)"} - 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 = $1)"} - 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 = $1)"} - 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 = $1", - "provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = $1)", - } - baseArgs := []interface{}{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 = $1 AND role = $2 AND created_at >= $3 - `, 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...) - idx := len(args) + 1 - - if opts.ExcludeBYOK { - where = append(where, fmt.Sprintf("provider_scope != $%d", idx)) - args = append(args, "personal") - idx++ - } - if opts.Since != nil { - where = append(where, fmt.Sprintf("created_at >= $%d", idx)) - args = append(args, *opts.Since) - idx++ - } - if opts.Until != nil { - where = append(where, fmt.Sprintf("created_at < $%d", idx)) - args = append(args, *opts.Until) - idx++ - } - - 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)::text" - case "user": - groupExpr = "user_id" - labelExpr = "user_id::text" - case "provider": - groupExpr = "provider_config_id" - labelExpr = "provider_config_id::text" - 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() -} diff --git a/server/store/postgres/user_mention.go b/server/store/postgres/user_mention.go deleted file mode 100644 index b75b49c..0000000 --- a/server/store/postgres/user_mention.go +++ /dev/null @@ -1,62 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - - "switchboard-core/store" -) - -// ── Mention resolution + display info (v0.29.0) ──────────────────────── - -func (s *UserStore) FindActiveByHandle(ctx context.Context, handle, excludeUserID string) (string, error) { - var id string - err := DB.QueryRowContext(ctx, ` - SELECT id FROM users - WHERE LOWER(handle) = LOWER($1) AND id != $2 AND is_active = true - LIMIT 1 - `, handle, excludeUserID).Scan(&id) - if err == sql.ErrNoRows { - return "", nil - } - return id, err -} - -func (s *UserStore) FindActiveByHandlePrefix(ctx context.Context, prefix, excludeUserID string) (string, int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM users - WHERE LOWER(handle) LIKE LOWER($1) AND id != $2 AND is_active = true - `, prefix+"%", excludeUserID).Scan(&count) - if err != nil { - return "", 0, err - } - if count != 1 { - return "", count, nil - } - var id string - err = DB.QueryRowContext(ctx, ` - SELECT id FROM users - WHERE LOWER(handle) LIKE LOWER($1) AND id != $2 AND is_active = true - LIMIT 1 - `, prefix+"%", excludeUserID).Scan(&id) - return id, 1, err -} - -func (s *UserStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) { - result := make(map[string]store.UserDisplayInfo) - for _, id := range ids { - var name, avatar sql.NullString - _ = DB.QueryRowContext(ctx, ` - SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1 - `, id).Scan(&name, &avatar) - if name.Valid { - info := store.UserDisplayInfo{Name: name.String} - if avatar.Valid { - info.Avatar = avatar.String - } - result[id] = info - } - } - return result, nil -} diff --git a/server/store/postgres/user_settings.go b/server/store/postgres/user_settings.go deleted file mode 100644 index c51f4d1..0000000 --- a/server/store/postgres/user_settings.go +++ /dev/null @@ -1,149 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - - "switchboard-core/models" -) - -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, provider_config_id, COALESCE(hidden, false), preferred_temperature, preferred_max_tokens, - COALESCE(sort_order, 0), created_at, updated_at - FROM user_model_settings WHERE user_id = $1 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.ProviderConfigID, &s.Hidden, - &prefTemp, &prefMaxTokens, - &s.SortOrder, &s.CreatedAt, &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 provider_config_id: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, COALESCE(provider_config_id::text, '') FROM user_model_settings WHERE user_id = $1 AND hidden = true", - userID) - if err != nil { - return nil, err - } - defer rows.Close() - - result := make(map[string]bool) - for rows.Next() { - var modelID, provCfgID string - if err := rows.Scan(&modelID, &provCfgID); err != nil { - return nil, err - } - result[models.CompositeModelKey(provCfgID, modelID)] = true - } - return result, rows.Err() -} - -// Set upserts a single user model setting. -func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, providerConfigID *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 - _, err := DB.ExecContext(ctx, ` - INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden, preferred_temperature, preferred_max_tokens, sort_order) - VALUES ($1, $2, $3, COALESCE($4, false), $5, $6, COALESCE($7, 0)) - ON CONFLICT (user_id, model_id, provider_config_id) - DO UPDATE SET - hidden = COALESCE($4, user_model_settings.hidden), - preferred_temperature = COALESCE($5, user_model_settings.preferred_temperature), - preferred_max_tokens = COALESCE($6, user_model_settings.preferred_max_tokens), - sort_order = COALESCE($7, user_model_settings.sort_order)`, - userID, modelID, providerConfigID, - patchBoolOrNil(patch.Hidden), - patchFloat64OrNil(patch.PreferredTemperature), - patchIntOrNil(patch.PreferredMaxTokens), - patchIntOrNil(patch.SortOrder), - ) - return err -} - -// BulkSetHidden sets the hidden state for multiple model+provider pairs at once. -func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error { - if len(entries) == 0 { - return nil - } - - for _, entry := range entries { - _, err := DB.ExecContext(ctx, ` - INSERT INTO user_model_settings (user_id, model_id, provider_config_id, hidden) - VALUES ($1, $2, $3, $4) - ON CONFLICT (user_id, model_id, provider_config_id) DO UPDATE SET hidden = $4`, - userID, entry.ModelID, entry.ProviderConfigID, hidden) - if err != nil { - return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.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 -} - diff --git a/server/store/postgres/workspace.go b/server/store/postgres/workspace.go deleted file mode 100644 index 39e8433..0000000 --- a/server/store/postgres/workspace.go +++ /dev/null @@ -1,398 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "fmt" - "strings" - "time" - - "switchboard-core/models" -) - -type WorkspaceStore struct{} - -func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} } - -// ── columns ──────────────────────────────── - -const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, indexing_enabled, git_remote_url, git_branch, git_credential_id, git_last_sync, created_at, updated_at` -const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, index_status, chunk_count, created_at, updated_at` - -// ── scanners ─────────────────────────────── - -func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) { - var w models.Workspace - var maxBytes sql.NullInt64 - var gitRemote, gitBranch, gitCredID sql.NullString - var gitSync sql.NullTime - err := row.Scan( - &w.ID, &w.OwnerType, &w.OwnerID, &w.Name, - &w.RootPath, &maxBytes, &w.Status, &w.IndexingEnabled, - &gitRemote, &gitBranch, &gitCredID, &gitSync, - &w.CreatedAt, &w.UpdatedAt, - ) - if err != nil { - return nil, err - } - if maxBytes.Valid { - w.MaxBytes = &maxBytes.Int64 - } - if gitRemote.Valid { - w.GitRemoteURL = &gitRemote.String - } - if gitBranch.Valid { - w.GitBranch = &gitBranch.String - } - if gitCredID.Valid { - w.GitCredentialID = &gitCredID.String - } - if gitSync.Valid { - w.GitLastSync = &gitSync.Time - } - return &w, nil -} - -func scanWorkspaceFile(row interface{ Scan(dest ...interface{}) error }) (*models.WorkspaceFile, error) { - var f models.WorkspaceFile - var contentType sql.NullString - var sha256 sql.NullString - var indexStatus sql.NullString - err := row.Scan( - &f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory, - &contentType, &f.SizeBytes, &sha256, - &indexStatus, &f.ChunkCount, - &f.CreatedAt, &f.UpdatedAt, - ) - if err != nil { - return nil, err - } - if contentType.Valid { - f.ContentType = contentType.String - } - if sha256.Valid { - f.SHA256 = sha256.String - } - if indexStatus.Valid { - f.IndexStatus = indexStatus.String - } - return &f, nil -} - -// ── Workspace CRUD ───────────────────────── - -func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error { - // Accept pre-generated ID or let Postgres generate one - if w.ID != "" { - return DB.QueryRowContext(ctx, ` - INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING created_at, updated_at`, - w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath, - w.MaxBytes, w.Status, - ).Scan(&w.CreatedAt, &w.UpdatedAt) - } - return DB.QueryRowContext(ctx, ` - INSERT INTO workspaces (owner_type, owner_id, name, root_path, max_bytes, status) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, created_at, updated_at`, - w.OwnerType, w.OwnerID, w.Name, w.RootPath, - w.MaxBytes, w.Status, - ).Scan(&w.ID, &w.CreatedAt, &w.UpdatedAt) -} - -func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) { - row := DB.QueryRowContext(ctx, - `SELECT `+workspaceCols+` FROM workspaces WHERE id = $1`, id) - return scanWorkspace(row) -} - -func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error { - b := NewUpdate("workspaces") - if patch.Name != nil { - b.Set("name", *patch.Name) - } - if patch.MaxBytes != nil { - b.Set("max_bytes", *patch.MaxBytes) - } - if patch.Status != nil { - b.Set("status", *patch.Status) - } - if patch.IndexingEnabled != nil { - b.Set("indexing_enabled", *patch.IndexingEnabled) - } - if patch.GitRemoteURL != nil { - b.Set("git_remote_url", *patch.GitRemoteURL) - } - if patch.GitBranch != nil { - b.Set("git_branch", *patch.GitBranch) - } - if patch.GitCredentialID != nil { - b.Set("git_credential_id", *patch.GitCredentialID) - } - if !b.HasSets() { - return nil - } - b.Set("updated_at", time.Now().UTC()) - b.Where("id", id) - _, err := b.Exec(DB) - return err -} - -func (s *WorkspaceStore) Delete(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, `DELETE FROM workspaces WHERE id = $1`, id) - return err -} - -// ── Ownership lookup ─────────────────────── - -func (s *WorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) { - row := DB.QueryRowContext(ctx, - `SELECT `+workspaceCols+` FROM workspaces - WHERE owner_type = $1 AND owner_id = $2 AND status != 'deleting' - ORDER BY created_at DESC LIMIT 1`, - ownerType, ownerID) - return scanWorkspace(row) -} - -func (s *WorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) { - rows, err := DB.QueryContext(ctx, - `SELECT `+workspaceCols+` FROM workspaces - WHERE owner_type = $1 AND owner_id = $2 AND status != 'deleting' - ORDER BY created_at DESC`, - ownerType, ownerID) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []models.Workspace - for rows.Next() { - w, err := scanWorkspace(rows) - if err != nil { - return nil, err - } - out = append(out, *w) - } - return out, rows.Err() -} - -// ── File index ───────────────────────────── - -func (s *WorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error { - return DB.QueryRowContext(ctx, ` - INSERT INTO workspace_files (workspace_id, path, is_directory, content_type, size_bytes, sha256) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (workspace_id, path) DO UPDATE SET - is_directory = EXCLUDED.is_directory, - content_type = EXCLUDED.content_type, - size_bytes = EXCLUDED.size_bytes, - sha256 = EXCLUDED.sha256, - updated_at = NOW() - RETURNING id, created_at, updated_at`, - f.WorkspaceID, f.Path, f.IsDirectory, - models.NullString(&f.ContentType), f.SizeBytes, - models.NullString(&f.SHA256), - ).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt) -} - -func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error { - _, err := DB.ExecContext(ctx, - `DELETE FROM workspace_files WHERE workspace_id = $1 AND path = $2`, - workspaceID, path) - return err -} - -func (s *WorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error { - _, err := DB.ExecContext(ctx, - `DELETE FROM workspace_files WHERE workspace_id = $1 AND path LIKE $2`, - workspaceID, prefix+"%") - return err -} - -func (s *WorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) { - row := DB.QueryRowContext(ctx, - `SELECT `+workspaceFileCols+` FROM workspace_files - WHERE workspace_id = $1 AND path = $2`, - workspaceID, path) - return scanWorkspaceFile(row) -} - -func (s *WorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) { - var query string - var args []interface{} - - if recursive { - // All files under prefix (recursive) - if prefix == "" { - query = `SELECT ` + workspaceFileCols + ` FROM workspace_files - WHERE workspace_id = $1 ORDER BY path` - args = []interface{}{workspaceID} - } else { - query = `SELECT ` + workspaceFileCols + ` FROM workspace_files - WHERE workspace_id = $1 AND path LIKE $2 ORDER BY path` - args = []interface{}{workspaceID, prefix + "%"} - } - } else { - // Direct children only: match prefix but no additional slashes - if prefix == "" { - // Root level: no slash in path - query = `SELECT ` + workspaceFileCols + ` FROM workspace_files - WHERE workspace_id = $1 AND path NOT LIKE '%/%' ORDER BY path` - args = []interface{}{workspaceID} - } else { - // Children of prefix: starts with prefix, no additional slash after prefix - query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files - WHERE workspace_id = $1 AND path LIKE $2 - AND substring(path FROM %d) NOT LIKE '%%/%%' - ORDER BY path`, len(prefix)+1) - args = []interface{}{workspaceID, prefix + "%"} - } - } - - rows, err := DB.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []models.WorkspaceFile - for rows.Next() { - f, err := scanWorkspaceFile(rows) - if err != nil { - return nil, err - } - out = append(out, *f) - } - return out, rows.Err() -} - -func (s *WorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error { - _, err := DB.ExecContext(ctx, - `DELETE FROM workspace_files WHERE workspace_id = $1`, workspaceID) - return err -} - -// ── Stats ────────────────────────────────── - -func (s *WorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) { - stats := &models.WorkspaceStats{WorkspaceID: workspaceID} - err := DB.QueryRowContext(ctx, ` - SELECT - COALESCE(SUM(CASE WHEN NOT is_directory THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN is_directory THEN 1 ELSE 0 END), 0), - COALESCE(SUM(size_bytes), 0) - FROM workspace_files WHERE workspace_id = $1`, - workspaceID, - ).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes) - if err != nil { - return nil, err - } - - // Fetch quota - var maxBytes sql.NullInt64 - err = DB.QueryRowContext(ctx, - `SELECT max_bytes FROM workspaces WHERE id = $1`, workspaceID, - ).Scan(&maxBytes) - if err != nil && err != sql.ErrNoRows { - return nil, err - } - if maxBytes.Valid { - stats.MaxBytes = &maxBytes.Int64 - } - - return stats, nil -} - -// ── Chunks (v0.21.2) ───────────────────────── - -func (s *WorkspaceStore) InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error { - if len(chunks) == 0 { - return nil - } - - const cols = 8 // workspace_id, file_id, chunk_index, content, token_count, embedding, metadata, created_at - valueParts := make([]string, 0, len(chunks)) - args := make([]interface{}, 0, len(chunks)*cols) - - for i, c := range chunks { - base := i * cols - valueParts = append(valueParts, fmt.Sprintf( - "($%d, $%d, $%d, $%d, $%d, $%d::vector, $%d, NOW())", - base+1, base+2, base+3, base+4, base+5, base+6, base+7, - )) - args = append(args, c.WorkspaceID, c.FileID, c.ChunkIndex, - c.Content, c.TokenCount, vectorToString(c.Embedding), ToJSON(c.Metadata)) - } - - q := fmt.Sprintf(`INSERT INTO workspace_chunks - (workspace_id, file_id, chunk_index, content, token_count, embedding, metadata, created_at) - VALUES %s`, strings.Join(valueParts, ", ")) - - _, err := DB.ExecContext(ctx, q, args...) - return err -} - -func (s *WorkspaceStore) DeleteChunksByFile(ctx context.Context, fileID string) error { - _, err := DB.ExecContext(ctx, `DELETE FROM workspace_chunks WHERE file_id = $1`, fileID) - return err -} - -func (s *WorkspaceStore) SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error) { - if len(queryVec) == 0 { - return nil, nil - } - if limit <= 0 { - limit = 10 - } - - rows, err := DB.QueryContext(ctx, ` - SELECT f.path, c.content, - 1 - (c.embedding <=> $1::vector) AS similarity, - c.metadata - FROM workspace_chunks c - JOIN workspace_files f ON c.file_id = f.id - WHERE c.workspace_id = $2 - AND 1 - (c.embedding <=> $1::vector) > $3 - ORDER BY c.embedding <=> $1::vector - LIMIT $4`, - vectorToString(queryVec), workspaceID, threshold, limit, - ) - if err != nil { - return nil, err - } - defer rows.Close() - - var results []models.WorkspaceChunkResult - for rows.Next() { - var r models.WorkspaceChunkResult - var metadataJSON []byte - if err := rows.Scan(&r.FilePath, &r.Content, &r.Score, &metadataJSON); err != nil { - return nil, err - } - ScanJSON(metadataJSON, &r.Metadata) - if r.Metadata != nil { - if lh, ok := r.Metadata["line_start"]; ok { - if n, ok := lh.(float64); ok { - r.LineHint = int(n) - } - } - } - results = append(results, r) - } - return results, rows.Err() -} - -func (s *WorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error { - _, err := DB.ExecContext(ctx, - `UPDATE workspace_files SET index_status = $1, chunk_count = $2, updated_at = NOW() WHERE id = $3`, - status, chunkCount, fileID) - return err -} - -func (s *WorkspaceStore) SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error { - _, err := DB.ExecContext(ctx, - `UPDATE workspaces SET git_last_sync = $1, updated_at = NOW() WHERE id = $2`, - t, workspaceID) - return err -} diff --git a/server/store/project_interface.go b/server/store/project_interface.go deleted file mode 100644 index eb3818d..0000000 --- a/server/store/project_interface.go +++ /dev/null @@ -1,70 +0,0 @@ -package store - -import ( - "context" - "time" - - "switchboard-core/models" -) - -// ========================================= -// PROJECT STORE (v0.19.0) -// ========================================= - -type ProjectStore interface { - // CRUD - Create(ctx context.Context, p *models.Project) error - GetByID(ctx context.Context, id string) (*models.Project, error) - Update(ctx context.Context, id string, patch models.ProjectPatch) error - Delete(ctx context.Context, id string) error - - // Listing - ListForUser(ctx context.Context, userID string, teamIDs []string, includeArchived bool) ([]models.Project, error) - - // Channel association (atomic move: removes old project if any) - AddChannel(ctx context.Context, projectID, channelID string, position int) error - RemoveChannel(ctx context.Context, projectID, channelID string) error - ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error) - ReorderChannels(ctx context.Context, projectID string, channelIDs []string) error - - // KB association - AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error - RemoveKB(ctx context.Context, projectID, kbID string) error - ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error) - - // Note association - AddNote(ctx context.Context, projectID, noteID string) error - RemoveNote(ctx context.Context, projectID, noteID string) error - ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error) - - // Access check - UserCanAccess(ctx context.Context, userID, projectID string, teamIDs []string) (bool, error) - - // Get project ID for a channel (used during note auto-association) - GetProjectIDForChannel(ctx context.Context, channelID string) (string, error) - - // Get KB IDs bound to a project (used for virtual injection at completion) - GetKBIDs(ctx context.Context, projectID string) ([]string, error) - - // ── CS5c additions (v0.29.0) ── - - // AdminList returns all projects with counts and owner names (admin only). - AdminList(ctx context.Context, includeArchived bool) ([]AdminProject, error) -} - -// AdminProject is returned by ProjectStore.AdminList. -type AdminProject struct { - ID string - Name string - Description string - Scope string - OwnerID string - TeamID *string - IsArchived bool - CreatedAt time.Time - UpdatedAt time.Time - ChannelCount int - KBCount int - NoteCount int - OwnerName string -} diff --git a/server/store/sqlite/cap_override.go b/server/store/sqlite/cap_override.go deleted file mode 100644 index f0fd30e..0000000 --- a/server/store/sqlite/cap_override.go +++ /dev/null @@ -1,90 +0,0 @@ -package sqlite - -import ( - "context" - - "switchboard-core/models" - "switchboard-core/store" -) - -type CapOverrideStore struct{} - -func NewCapOverrideStore() *CapOverrideStore { return &CapOverrideStore{} } - -func (s *CapOverrideStore) Set(ctx context.Context, o *models.CapabilityOverride) error { - id := store.NewID() - _, err := DB.ExecContext(ctx, ` - INSERT INTO capability_overrides (id, provider_config_id, model_id, field, value, set_by) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT (provider_config_id, model_id, field) DO UPDATE SET - value = excluded.value, - set_by = excluded.set_by, - created_at = datetime('now') - `, id, o.ProviderConfigID, o.ModelID, o.Field, o.Value, o.SetBy) - return err -} - -func (s *CapOverrideStore) Delete(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, `DELETE FROM capability_overrides WHERE id = ?`, id) - return err -} - -func (s *CapOverrideStore) ListForModel(ctx context.Context, modelID string) ([]models.CapabilityOverride, error) { - return s.query(ctx, ` - SELECT id, provider_config_id, model_id, field, value, set_by, created_at - FROM capability_overrides - WHERE model_id = ? - ORDER BY provider_config_id, field - `, modelID) -} - -func (s *CapOverrideStore) ListForProviderModel(ctx context.Context, providerConfigID, modelID string) ([]models.CapabilityOverride, error) { - return s.query(ctx, ` - SELECT id, provider_config_id, model_id, field, value, set_by, created_at - FROM capability_overrides - WHERE model_id = ? AND (provider_config_id = ? OR provider_config_id IS NULL) - ORDER BY provider_config_id, field - `, modelID, providerConfigID) -} - -func (s *CapOverrideStore) ListAll(ctx context.Context) ([]models.CapabilityOverride, error) { - return s.query(ctx, ` - SELECT id, provider_config_id, model_id, field, value, set_by, created_at - FROM capability_overrides - ORDER BY model_id, provider_config_id, field - `) -} - -func (s *CapOverrideStore) DeleteForProvider(ctx context.Context, providerConfigID string) error { - _, err := DB.ExecContext(ctx, ` - DELETE FROM capability_overrides WHERE provider_config_id = ? - `, providerConfigID) - return err -} - -func (s *CapOverrideStore) query(ctx context.Context, q string, args ...interface{}) ([]models.CapabilityOverride, error) { - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.CapabilityOverride - for rows.Next() { - var o models.CapabilityOverride - if err := rows.Scan(&o.ID, &o.ProviderConfigID, &o.ModelID, &o.Field, &o.Value, &o.SetBy, &o.CreatedAt); err != nil { - return nil, err - } - result = append(result, o) - } - return result, rows.Err() -} - -// ensure interface compliance (compile-time check) -var _ store.CapabilityOverrideStore = (*CapOverrideStore)(nil) - -// also check the health store interface at this package level -// (health.Store is in the health package, but we verify via the sql.Rows pattern) -func init() { - // compile-time interface checks happen via the var _ lines above -} diff --git a/server/store/sqlite/catalog.go b/server/store/sqlite/catalog.go deleted file mode 100644 index 88578fe..0000000 --- a/server/store/sqlite/catalog.go +++ /dev/null @@ -1,333 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "time" - - "switchboard-core/models" - "switchboard-core/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). -// -// Runs in a single transaction: one SELECT to identify existing models, -// then N upserts via a prepared INSERT ON CONFLICT statement, one COMMIT. -// SQLite with WAL benefits enormously — one journal cycle instead of N. -func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID string, entries []store.CatalogSyncEntry) (added, updated int, err error) { - if len(entries) == 0 { - return 0, 0, nil - } - - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return 0, 0, fmt.Errorf("begin tx: %w", err) - } - defer tx.Rollback() - - // Pre-fetch existing model_ids for this provider to track added vs updated. - existing := make(map[string]bool) - rows, err := tx.QueryContext(ctx, - "SELECT model_id FROM model_catalog WHERE provider_config_id = ?", - providerConfigID) - if err != nil { - return 0, 0, fmt.Errorf("list existing: %w", err) - } - for rows.Next() { - var mid string - if err := rows.Scan(&mid); err != nil { - rows.Close() - return 0, 0, fmt.Errorf("scan existing: %w", err) - } - existing[mid] = true - } - rows.Close() - if err := rows.Err(); err != nil { - return 0, 0, fmt.Errorf("list existing: %w", err) - } - - // Prepare the upsert statement once, execute N times. - // SQLite needs an explicit id value (TEXT PK, no auto-generation). - // On conflict the id is preserved — only metadata columns update. - stmt, err := tx.PrepareContext(ctx, ` - INSERT INTO model_catalog (id, provider_config_id, model_id, display_name, - model_type, capabilities, pricing, visibility, last_synced_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 'disabled', ?) - ON CONFLICT (provider_config_id, model_id) DO UPDATE SET - display_name = excluded.display_name, - model_type = excluded.model_type, - capabilities = excluded.capabilities, - pricing = excluded.pricing, - last_synced_at = excluded.last_synced_at`) - if err != nil { - return 0, 0, fmt.Errorf("prepare upsert: %w", err) - } - defer stmt.Close() - - now := time.Now().UTC().Format("2006-01-02 15:04:05") - for _, e := range entries { - capsJSON := ToJSON(e.Capabilities) - pricingJSON := ToJSON(e.Pricing) - - modelType := e.ModelType - if modelType == "" { - modelType = "chat" - } - - if _, err := stmt.ExecContext(ctx, - store.NewID(), providerConfigID, e.ModelID, e.DisplayName, - modelType, capsJSON, pricingJSON, now, - ); err != nil { - return added, updated, fmt.Errorf("upsert %s: %w", e.ModelID, err) - } - - if existing[e.ModelID] { - updated++ - } else { - added++ - } - } - - if err := tx.Commit(); err != nil { - return 0, 0, fmt.Errorf("commit: %w", 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 personas 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() -} - -// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *CatalogStore) GetCapabilities(ctx context.Context, modelID, configID string) ([]byte, error) { - var capsJSON []byte - err := DB.QueryRowContext(ctx, ` - SELECT capabilities FROM model_catalog - WHERE model_id = ? AND provider_config_id = ? - `, modelID, configID).Scan(&capsJSON) - if err != nil { - return nil, err - } - return capsJSON, nil -} - -func (s *CatalogStore) GetCapabilitiesAny(ctx context.Context, modelID string) ([]byte, error) { - var capsJSON []byte - err := DB.QueryRowContext(ctx, ` - SELECT capabilities FROM model_catalog - WHERE model_id = ? ORDER BY last_synced_at DESC LIMIT 1 - `, modelID).Scan(&capsJSON) - if err != nil { - return nil, err - } - return capsJSON, nil -} - -// ── CS6 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *CatalogStore) ListTeamAvailable(ctx context.Context) ([]store.TeamAvailableModel, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT mc.id, mc.model_id, mc.display_name, mc.visibility, - ac.provider, ac.name AS provider_name - FROM model_catalog mc - JOIN provider_configs ac ON mc.provider_config_id = ac.id - WHERE mc.visibility IN ('enabled', 'team') - AND ac.is_active = 1 AND ac.scope = 'global' - ORDER BY ac.name, mc.model_id - `) - if err != nil { - return nil, err - } - defer rows.Close() - - results := make([]store.TeamAvailableModel, 0) - for rows.Next() { - var m store.TeamAvailableModel - if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility, - &m.Provider, &m.ProviderName); err != nil { - continue - } - results = append(results, m) - } - return results, rows.Err() -} diff --git a/server/store/sqlite/catalog_mention.go b/server/store/sqlite/catalog_mention.go deleted file mode 100644 index 5068a05..0000000 --- a/server/store/sqlite/catalog_mention.go +++ /dev/null @@ -1,57 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" -) - -// ── Mention resolution (v0.29.0) ──────────────────────────────────────── - -func (s *CatalogStore) FindEnabledByModelID(ctx context.Context, modelID string) (string, string, error) { - var foundModelID, configID string - err := DB.QueryRowContext(ctx, ` - SELECT mc.model_id, mc.provider_config_id - FROM model_catalog mc - JOIN provider_configs pc ON pc.id = mc.provider_config_id - WHERE LOWER(mc.model_id) = LOWER(?) - AND mc.visibility = 'enabled' - AND pc.is_active = 1 - ORDER BY - CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END - LIMIT 1 - `, modelID).Scan(&foundModelID, &configID) - if err == sql.ErrNoRows { - return "", "", nil - } - return foundModelID, configID, err -} - -func (s *CatalogStore) FindEnabledByModelIDPrefix(ctx context.Context, prefix string) (string, string, int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(DISTINCT mc.model_id) - FROM model_catalog mc - JOIN provider_configs pc ON pc.id = mc.provider_config_id - WHERE LOWER(mc.model_id) LIKE LOWER(?) - AND mc.visibility = 'enabled' - AND pc.is_active = 1 - `, prefix+"%").Scan(&count) - if err != nil { - return "", "", 0, err - } - if count != 1 { - return "", "", count, nil - } - var foundModelID, configID string - err = DB.QueryRowContext(ctx, ` - SELECT mc.model_id, mc.provider_config_id - FROM model_catalog mc - JOIN provider_configs pc ON pc.id = mc.provider_config_id - WHERE LOWER(mc.model_id) LIKE LOWER(?) - AND mc.visibility = 'enabled' - AND pc.is_active = 1 - ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END - LIMIT 1 - `, prefix+"%").Scan(&foundModelID, &configID) - return foundModelID, configID, 1, err -} diff --git a/server/store/sqlite/channel.go b/server/store/sqlite/channel.go deleted file mode 100644 index cc680e7..0000000 --- a/server/store/sqlite/channel.go +++ /dev/null @@ -1,972 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "strings" - "time" - - "switchboard-core/models" - "switchboard-core/store" -) - -type ChannelStore struct{} - -func NewChannelStore() *ChannelStore { return &ChannelStore{} } - -func (s *ChannelStore) Create(ctx context.Context, ch *models.Channel) error { - ch.ID = store.NewID() - now := time.Now().UTC() - ch.CreatedAt = now - ch.UpdatedAt = now - _, err := DB.ExecContext(ctx, ` - INSERT INTO channels (id, user_id, title, description, type, model, system_prompt, - provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - ch.ID, ch.UserID, ch.Title, ch.Description, ch.Type, ch.Model, ch.SystemPrompt, - models.NullString(ch.ProviderConfigID), ch.IsArchived, ch.IsPinned, - models.NullString(ch.FolderID), models.NullString(ch.TeamID), ToJSON(ch.Settings), - now.Format(timeFmt), now.Format(timeFmt), - ) - return err -} - -func (s *ChannelStore) GetByID(ctx context.Context, id string) (*models.Channel, error) { - var ch models.Channel - var providerConfigID, folderID, teamID sql.NullString - var desc sql.NullString - var settingsJSON []byte - err := DB.QueryRowContext(ctx, ` - SELECT id, user_id, title, description, type, model, system_prompt, - provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, - created_at, updated_at - FROM channels WHERE id = ?`, id).Scan( - &ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, &ch.SystemPrompt, - &providerConfigID, &ch.IsArchived, &ch.IsPinned, &folderID, &teamID, &settingsJSON, - st(&ch.CreatedAt), st(&ch.UpdatedAt), - ) - if err != nil { - return nil, err - } - ch.Description = NullableString(desc) - ch.ProviderConfigID = NullableStringPtr(providerConfigID) - ch.FolderID = NullableStringPtr(folderID) - ch.TeamID = NullableStringPtr(teamID) - json.Unmarshal(settingsJSON, &ch.Settings) - return &ch, nil -} - -func (s *ChannelStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { - b := NewUpdate("channels") - for k, v := range fields { - if k == "settings" || k == "tags" { - b.SetJSON(k, v) - } else { - b.Set(k, v) - } - } - if !b.HasSets() { - return nil - } - b.Where("id", id) - _, err := b.Exec(DB) - return err -} - -func (s *ChannelStore) Delete(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, "DELETE FROM channels WHERE id = ?", id) - return err -} - -func (s *ChannelStore) ListForUser(ctx context.Context, userID string, opts store.ListOptions) ([]models.Channel, int, error) { - // Count - var total int - DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels WHERE user_id = ?", userID).Scan(&total) - - b := NewSelect( - "id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at", - "channels", - ).Where("user_id = ?", userID) - - if opts.Sort == "" { - b.OrderBy("updated_at", "DESC") - } - b.Paginate(opts) - - q, args := b.Build() - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var result []models.Channel - for rows.Next() { - var ch models.Channel - var providerConfigID, folderID, teamID, desc sql.NullString - var settingsJSON []byte - err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, - &ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned, - &folderID, &teamID, &settingsJSON, st(&ch.CreatedAt), st(&ch.UpdatedAt)) - if err != nil { - return nil, 0, err - } - ch.Description = NullableString(desc) - ch.ProviderConfigID = NullableStringPtr(providerConfigID) - ch.FolderID = NullableStringPtr(folderID) - ch.TeamID = NullableStringPtr(teamID) - json.Unmarshal(settingsJSON, &ch.Settings) - result = append(result, ch) - } - return result, total, rows.Err() -} - -func (s *ChannelStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Channel, int, error) { - // Simple title search for now — will add full-text when search feature lands - b := NewSelect( - "id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at", - "channels", - ).Where("user_id = ?", userID).Where("title LIKE ?", "%"+query+"%") - b.OrderBy("updated_at", "DESC") - b.Paginate(opts) - - var total int - DB.QueryRowContext(ctx, - "SELECT COUNT(*) FROM channels WHERE user_id = ? AND title LIKE ?", - userID, "%"+query+"%").Scan(&total) - - q, args := b.Build() - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var result []models.Channel - for rows.Next() { - var ch models.Channel - var providerConfigID, folderID, teamID, desc sql.NullString - var settingsJSON []byte - err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, - &ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned, - &folderID, &teamID, &settingsJSON, st(&ch.CreatedAt), st(&ch.UpdatedAt)) - if err != nil { - return nil, 0, err - } - ch.Description = NullableString(desc) - ch.ProviderConfigID = NullableStringPtr(providerConfigID) - ch.FolderID = NullableStringPtr(folderID) - ch.TeamID = NullableStringPtr(teamID) - json.Unmarshal(settingsJSON, &ch.Settings) - result = append(result, ch) - } - return result, total, rows.Err() -} - -func (s *ChannelStore) GetCursor(ctx context.Context, channelID, userID string) (*models.ChannelCursor, error) { - var c models.ChannelCursor - var leafID sql.NullString - err := DB.QueryRowContext(ctx, ` - SELECT id, channel_id, user_id, active_leaf_id, updated_at - FROM channel_cursors WHERE channel_id = ? AND user_id = ?`, - channelID, userID).Scan(&c.ID, &c.ChannelID, &c.UserID, &leafID, st(&c.UpdatedAt)) - if err != nil { - return nil, err - } - c.ActiveLeafID = NullableStringPtr(leafID) - return &c, nil -} - -func (s *ChannelStore) SetCursor(ctx context.Context, channelID, userID, leafID string) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO channel_cursors (id, channel_id, user_id, active_leaf_id) - VALUES (?, ?, ?, ?) - ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = excluded.active_leaf_id, updated_at = datetime('now')`, - store.NewID(), channelID, userID, leafID) - return err -} - -func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error { - // For persona entries, use the persona-aware path - if cm.PersonaID != nil && *cm.PersonaID != "" { - return s.SetPersonaModel(ctx, cm) - } - // Nullable provider_config_id: empty string → SQL NULL (UUID FK) - var provCfgID interface{} = cm.ProviderConfigID - if cm.ProviderConfigID == "" { - provCfgID = nil - } - // Raw model upsert (no persona) — matches idx_channel_models_raw partial index - _, err := DB.ExecContext(ctx, ` - INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET - display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`, - store.NewID(), cm.ChannelID, cm.ModelID, provCfgID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault) - return err -} - -// SetPersonaModel inserts or updates a persona's channel model roster entry. -func (s *ChannelStore) SetPersonaModel(ctx context.Context, cm *models.ChannelModel) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, persona_id, display_name, system_prompt, settings, is_default) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (channel_id, persona_id) WHERE persona_id IS NOT NULL DO UPDATE SET - model_id = excluded.model_id, provider_config_id = excluded.provider_config_id, display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`, - store.NewID(), cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.PersonaID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault) - return err -} - -func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''), - COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''), - COALESCE(cm.system_prompt, ''), cm.is_default - FROM channel_models cm - LEFT JOIN personas p ON p.id = cm.persona_id - WHERE cm.channel_id = ?`, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.ChannelModel - for rows.Next() { - var cm models.ChannelModel - var personaID string - if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID, - &personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil { - return nil, err - } - if personaID != "" { - cm.PersonaID = &personaID - } - result = append(result, cm) - } - return result, rows.Err() -} - -func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) { - var cm models.ChannelModel - var personaID string - err := DB.QueryRowContext(ctx, ` - SELECT cm.id, cm.channel_id, cm.model_id, COALESCE(cm.provider_config_id, ''), - COALESCE(cm.persona_id, ''), COALESCE(p.handle, ''), COALESCE(cm.display_name, ''), - COALESCE(cm.system_prompt, ''), cm.is_default - FROM channel_models cm - LEFT JOIN personas p ON p.id = cm.persona_id - WHERE cm.id = ?`, id).Scan( - &cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID, - &personaID, &cm.Handle, &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault) - if err != nil { - return nil, err - } - if personaID != "" { - cm.PersonaID = &personaID - } - return &cm, nil -} - -func (s *ChannelStore) UpdateModel(ctx context.Context, id string, fields map[string]interface{}) error { - if len(fields) == 0 { - return nil - } - sets := make([]string, 0, len(fields)) - args := make([]interface{}, 0, len(fields)+1) - for col, val := range fields { - sets = append(sets, col+" = ?") - args = append(args, val) - } - args = append(args, id) - query := "UPDATE channel_models SET " + strings.Join(sets, ", ") + " WHERE id = ?" - _, err := DB.ExecContext(ctx, query, args...) - return err -} - -func (s *ChannelStore) DeleteModel(ctx context.Context, id string) error { - res, err := DB.ExecContext(ctx, `DELETE FROM channel_models WHERE id = ?`, id) - if err != nil { - return err - } - n, _ := res.RowsAffected() - if n == 0 { - return sql.ErrNoRows - } - return nil -} - -func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) (bool, error) { - var exists bool - err := DB.QueryRowContext(ctx, - "SELECT EXISTS(SELECT 1 FROM channels WHERE id = ? AND user_id = ?)", - channelID, userID).Scan(&exists) - return exists, err -} - -// ResolveWorkspaceID returns the effective workspace for a channel. -// Resolution: channel.workspace_id > project.workspace_id. -// Returns empty string if no workspace is bound. -func (s *ChannelStore) ResolveWorkspaceID(ctx context.Context, channelID string) (string, error) { - var wsID sql.NullString - err := DB.QueryRowContext(ctx, ` - SELECT COALESCE(c.workspace_id, p.workspace_id) - FROM channels c - LEFT JOIN project_channels pc ON pc.channel_id = c.id - LEFT JOIN projects p ON p.id = pc.project_id - WHERE c.id = ? - LIMIT 1`, channelID).Scan(&wsID) - if err != nil { - if err == sql.ErrNoRows { - return "", nil - } - return "", err - } - return NullableString(wsID), nil -} - -// ── Channel Participants (ICD §3.7) ────────── - -func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelParticipant) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role, display_name, avatar_url) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - store.NewID(), p.ChannelID, p.ParticipantType, p.ParticipantID, p.Role, p.DisplayName, p.AvatarURL) - return err -} - -func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT cp.id, cp.channel_id, cp.participant_type, cp.participant_id, cp.role, - COALESCE(NULLIF(cp.display_name,''), NULLIF(u.display_name,''), u.username) AS display_name, - COALESCE(NULLIF(cp.avatar_url,''), u.avatar_url) AS avatar_url, - cp.joined_at - FROM channel_participants cp - LEFT JOIN users u ON cp.participant_type = 'user' AND cp.participant_id = u.id - WHERE cp.channel_id = ? ORDER BY cp.joined_at`, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.ChannelParticipant - for rows.Next() { - var p models.ChannelParticipant - var joinedStr string - if err := rows.Scan(&p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID, - &p.Role, &p.DisplayName, &p.AvatarURL, &joinedStr); err != nil { - return nil, err - } - p.JoinedAt, _ = time.Parse("2006-01-02 15:04:05", joinedStr) - result = append(result, p) - } - return result, rows.Err() -} - -func (s *ChannelStore) GetParticipantByID(ctx context.Context, id string) (*models.ChannelParticipant, error) { - var p models.ChannelParticipant - var joinedStr string - err := DB.QueryRowContext(ctx, ` - SELECT id, channel_id, participant_type, participant_id, role, - display_name, avatar_url, joined_at - FROM channel_participants WHERE id = ?`, id).Scan( - &p.ID, &p.ChannelID, &p.ParticipantType, &p.ParticipantID, - &p.Role, &p.DisplayName, &p.AvatarURL, &joinedStr) - if err != nil { - return nil, err - } - p.JoinedAt, _ = time.Parse("2006-01-02 15:04:05", joinedStr) - return &p, nil -} - -func (s *ChannelStore) UpdateParticipantRole(ctx context.Context, id, role string) error { - _, err := DB.ExecContext(ctx, `UPDATE channel_participants SET role = ? WHERE id = ?`, role, id) - return err -} - -func (s *ChannelStore) RemoveParticipant(ctx context.Context, id string) error { - res, err := DB.ExecContext(ctx, `DELETE FROM channel_participants WHERE id = ?`, id) - if err != nil { - return err - } - n, _ := res.RowsAffected() - if n == 0 { - return sql.ErrNoRows - } - return nil -} - -func (s *ChannelStore) IsParticipant(ctx context.Context, channelID, pType, pID string) (bool, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM channel_participants - WHERE channel_id = ? AND participant_type = ? AND participant_id = ?`, - channelID, pType, pID).Scan(&count) - return count > 0, err -} - -func (s *ChannelStore) GetParticipantRole(ctx context.Context, channelID, pType, pID string) (string, error) { - var role string - err := DB.QueryRowContext(ctx, ` - SELECT role FROM channel_participants - WHERE channel_id = ? AND participant_type = ? AND participant_id = ?`, - channelID, pType, pID).Scan(&role) - return role, err -} - -func (s *ChannelStore) CountParticipantsByRole(ctx context.Context, channelID, role string) (int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM channel_participants WHERE channel_id = ? AND role = ?`, - channelID, role).Scan(&count) - return count, err -} - -func (s *ChannelStore) DeleteModelByPersona(ctx context.Context, channelID, personaID string) error { - _, err := DB.ExecContext(ctx, - `DELETE FROM channel_models WHERE channel_id = ? AND persona_id = ?`, - channelID, personaID) - return err -} - -// ── Admin: Archived Channel Management ────── - -func (s *ChannelStore) ListArchived(ctx context.Context, opts store.ListOptions) ([]store.ArchivedChannel, int, error) { - // Count - var total int - DB.QueryRowContext(ctx, - `SELECT COUNT(*) FROM channels WHERE is_archived = 1`).Scan(&total) - - rows, err := DB.QueryContext(ctx, ` - SELECT c.id, c.title, c.type, COALESCE(u.username, '') AS owner_name, - c.updated_at, - (SELECT COUNT(*) FROM messages WHERE channel_id = c.id) AS message_count - FROM channels c - LEFT JOIN users u ON u.id = c.user_id - WHERE c.is_archived = 1 - ORDER BY c.updated_at DESC - LIMIT ? OFFSET ?`, opts.Limit, opts.Offset) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var channels []store.ArchivedChannel - for rows.Next() { - var ch store.ArchivedChannel - if rows.Scan(&ch.ID, &ch.Title, &ch.Type, &ch.OwnerName, &ch.UpdatedAt, &ch.MessageCount) == nil { - channels = append(channels, ch) - } - } - if channels == nil { - channels = []store.ArchivedChannel{} - } - return channels, total, rows.Err() -} - -func (s *ChannelStore) Purge(ctx context.Context, id string) error { - // Verify the channel is actually archived - var isArchived int - err := DB.QueryRowContext(ctx, - `SELECT is_archived FROM channels WHERE id = ?`, id).Scan(&isArchived) - if err != nil { - return fmt.Errorf("channel not found") - } - if isArchived == 0 { - return fmt.Errorf("channel must be archived before purging") - } - - // Hard delete (CASCADE removes messages, participants, models) - _, err = DB.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, id) - return err -} -// ── CS1 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *ChannelStore) FindExistingDM(ctx context.Context, userID1, userID2 string) (string, error) { - var channelID string - err := DB.QueryRowContext(ctx, ` - SELECT cp1.channel_id FROM channel_participants cp1 - JOIN channel_participants cp2 ON cp1.channel_id = cp2.channel_id - JOIN channels c ON c.id = cp1.channel_id - WHERE c.type = 'dm' - AND cp1.participant_type = 'user' AND cp1.participant_id = ? - AND cp2.participant_type = 'user' AND cp2.participant_id = ? - LIMIT 1 - `, userID1, userID2).Scan(&channelID) - if err == sql.ErrNoRows { - return "", nil - } - return channelID, err -} - -func (s *ChannelStore) GetUnreadCount(ctx context.Context, channelID, userID string) (int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM messages m - JOIN channel_participants cp ON cp.channel_id = m.channel_id - WHERE cp.channel_id = ? - AND cp.participant_type = 'user' AND cp.participant_id = ? - AND m.created_at > cp.last_read_at - `, channelID, userID).Scan(&count) - return count, err -} - -func (s *ChannelStore) DeleteByOwner(ctx context.Context, channelID, userID string) (int64, error) { - result, err := DB.ExecContext(ctx, - `DELETE FROM channels WHERE id = ? AND user_id = ?`, - channelID, userID) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -func (s *ChannelStore) ArchiveForRetention(ctx context.Context, channelID string, purgeAfter time.Time) error { - _, err := DB.ExecContext(ctx, ` - UPDATE channels SET is_archived = 1, purge_after = ?, updated_at = datetime('now') - WHERE id = ? - `, purgeAfter.UTC().Format("2006-01-02T15:04:05Z"), channelID) - return err -} - -func (s *ChannelStore) ListPurgeable(ctx context.Context) ([]string, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id FROM channels WHERE purge_after IS NOT NULL AND purge_after <= datetime('now') - `) - if err != nil { - return nil, err - } - defer rows.Close() - var ids []string - for rows.Next() { - var id string - if rows.Scan(&id) == nil { - ids = append(ids, id) - } - } - return ids, rows.Err() -} - -func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error { - _, err := DB.ExecContext(ctx, ` - UPDATE channel_participants - SET last_read_at = datetime('now') - WHERE channel_id = ? AND participant_type = 'user' AND participant_id = ? - `, channelID, userID) - if err != nil { - return nil - } - - var latestMsgID *string - _ = DB.QueryRowContext(ctx, ` - SELECT id FROM messages WHERE channel_id = ? ORDER BY created_at DESC LIMIT 1 - `, channelID).Scan(&latestMsgID) - if latestMsgID != nil { - _, _ = DB.ExecContext(ctx, ` - UPDATE channel_participants - SET last_read_message_id = ? - WHERE channel_id = ? AND participant_type = 'user' AND participant_id = ? - `, *latestMsgID, channelID, userID) - } - - return nil -} - -func (s *ChannelStore) CountParticipantsByType(ctx context.Context, channelID, pType string) (int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM channel_participants - WHERE channel_id = ? AND participant_type = ? - `, channelID, pType).Scan(&count) - return count, err -} - -// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *ChannelStore) CountAll(ctx context.Context) (int, error) { - var count int - err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&count) - return count, err -} - -// ── Workflow instance state (v0.29.0-cs3) ─────────────────────────────── - -func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error { - now := time.Now().UTC().Format(timeFmt) - _, err := DB.ExecContext(ctx, ` - UPDATE channels - SET workflow_id = ?, workflow_version = ?, current_stage = 0, - stage_data = ?, workflow_status = ?, last_activity_at = ?, - stage_entered_at = ? - WHERE id = ? - `, workflowID, version, stageData, status, now, now, channelID) - return err -} - -func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string) (*store.WorkflowChannelStatus, error) { - var ws store.WorkflowChannelStatus - var stageData []byte - err := DB.QueryRowContext(ctx, ` - SELECT workflow_id, workflow_version, current_stage, - COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'), - last_activity_at, stage_entered_at - FROM channels WHERE id = ? AND type = 'workflow' - `, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion, - &ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt, &ws.StageEnteredAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - ws.StageData = stageData - return &ws, nil -} - -func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error { - now := time.Now().UTC().Format(timeFmt) - _, err := DB.ExecContext(ctx, ` - UPDATE channels - SET current_stage = ?, stage_data = ?, last_activity_at = ?, - stage_entered_at = ? - WHERE id = ? - `, nextStage, stageData, now, now, channelID) - return err -} - -func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, finalStage int, stageData json.RawMessage) error { - _, err := DB.ExecContext(ctx, ` - UPDATE channels - SET current_stage = ?, workflow_status = 'completed', - stage_data = ?, last_activity_at = ?, ai_mode = 'off' - WHERE id = ? - `, finalStage, stageData, time.Now().UTC().Format(timeFmt), channelID) - return err -} - -func (s *ChannelStore) CancelWorkflow(ctx context.Context, channelID string) error { - _, err := DB.ExecContext(ctx, ` - UPDATE channels - SET workflow_status = 'cancelled', ai_mode = 'off', last_activity_at = ? - WHERE id = ? AND type = 'workflow' - `, time.Now().UTC().Format(timeFmt), channelID) - return err -} - -func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error { - now := time.Now().UTC().Format(timeFmt) - _, err := DB.ExecContext(ctx, ` - UPDATE channels SET current_stage = ?, last_activity_at = ?, - stage_entered_at = ? WHERE id = ? - `, stage, now, now, channelID) - return err -} - -func (s *ChannelStore) GetStageData(ctx context.Context, channelID string) (json.RawMessage, error) { - var data json.RawMessage - err := DB.QueryRowContext(ctx, ` - SELECT COALESCE(stage_data, '{}') FROM channels WHERE id = ? - `, channelID).Scan(&data) - return data, err -} - -// ── Background job helpers (v0.29.0-cs4) ──────────────────────────────── - -func (s *ChannelStore) MarkStaleWorkflows(ctx context.Context, cutoff time.Time) (int64, error) { - result, err := DB.ExecContext(ctx, ` - UPDATE channels - SET workflow_status = 'stale' - WHERE type = 'workflow' - AND workflow_status = 'active' - AND last_activity_at < ? - `, cutoff.Format(timeFmt)) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -func (s *ChannelStore) EnforceWorkflowRetention(ctx context.Context) (int64, error) { - // SQLite doesn't support JSON operators for retention policy evaluation. - // This is a no-op on SQLite; retention enforcement is PG-only. - return 0, nil -} - -func (s *ChannelStore) GetTypeAndAllowAnonymous(ctx context.Context, channelID string) (string, bool, error) { - var chType string - var allowAnon bool - err := DB.QueryRowContext(ctx, - `SELECT type, allow_anonymous FROM channels WHERE id = ?`, channelID).Scan(&chType, &allowAnon) - return chType, allowAnon, err -} - -// ── CS5b additions (v0.29.0) ──────────────────────────────────────────── - -func (s *ChannelStore) FindCompactionCandidates(ctx context.Context, activityBefore, createdAfter time.Time, minMessages, minChars, limit int) ([]models.Channel, error) { - // Auto-compaction scanner is PG-only (uses settings::text cast). - return []models.Channel{}, nil -} - -// ── CS7a additions (v0.29.0) ──────────────────────────────────────────── - -func (s *ChannelStore) GetProviderConfigID(ctx context.Context, channelID string) (*string, error) { - var configID sql.NullString - err := DB.QueryRowContext(ctx, - `SELECT provider_config_id FROM channels WHERE id = ?`, channelID).Scan(&configID) - if err != nil { - return nil, err - } - return NullableStringPtr(configID), nil -} - -func (s *ChannelStore) UserCanAccess(ctx context.Context, channelID, userID string) (bool, error) { - var ok bool - err := DB.QueryRowContext(ctx, ` - SELECT EXISTS( - SELECT 1 FROM channels WHERE id = ? AND user_id = ? - UNION ALL - SELECT 1 FROM channel_participants - WHERE channel_id = ? AND participant_type = 'user' AND participant_id = ? - LIMIT 1 - )`, channelID, userID, channelID, userID).Scan(&ok) - return ok, err -} - -// ── CS7b additions (v0.29.0) ──────────────────────────────────────────── - -const channelListCols = `c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic, - c.description, c.model, c.provider_config_id, - c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.folder_id, c.project_id, c.workspace_id, - c.tags, c.settings, - COALESCE(mc.cnt, 0) AS message_count, - c.created_at, c.updated_at` - -const channelListFrom = `channels c - LEFT JOIN ( - SELECT channel_id, COUNT(*) AS cnt FROM messages WHERE deleted_at IS NULL GROUP BY channel_id - ) mc ON mc.channel_id = c.id` - -func (s *ChannelStore) ListFiltered(ctx context.Context, userID string, f store.ChannelListFilter) ([]store.ChannelListItem, int, error) { - b := NewSelect(channelListCols, channelListFrom) - b.Where("(c.user_id = ? OR c.id IN (SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = ?))", userID, userID) - archivedVal := 0 - if f.Archived { - archivedVal = 1 - } - b.Where("c.is_archived = ?", archivedVal) - - if len(f.Types) == 1 { - b.Where("c.type = ?", f.Types[0]) - } else if len(f.Types) > 1 { - placeholders := make([]string, len(f.Types)) - for i, t := range f.Types { - placeholders[i] = "?" - b.args = append(b.args, t) - } - b.WhereRaw("c.type IN (" + strings.Join(placeholders, ",") + ")") - } - if f.Folder != "" { - b.Where("c.folder = ?", f.Folder) - } - if f.FolderID != "" { - b.Where("c.folder_id = ?", f.FolderID) - } - if f.Search != "" { - b.Where("c.title LIKE ?", "%"+f.Search+"%") - } - if f.ProjectID == "none" { - b.WhereRaw("c.project_id IS NULL") - } else if f.ProjectID != "" { - b.Where("c.project_id = ?", f.ProjectID) - } - - // Count - countQ, countArgs := b.CountBuild() - var total int - DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total) - - b.OrderBy("c.is_pinned DESC, c.updated_at", "DESC") - b.Paginate(f.ListOptions) - - q, args := b.Build() - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - items, err := scanChannelListItems(rows) - if err != nil { - return nil, 0, err - } - - // Compute unread counts - for i := range items { - DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM messages m - JOIN channel_participants cp ON cp.channel_id = m.channel_id - WHERE cp.channel_id = ? - AND cp.participant_type = 'user' AND cp.participant_id = ? - AND m.created_at > cp.last_read_at - AND m.deleted_at IS NULL - `, items[i].ID, userID).Scan(&items[i].UnreadCount) - } - - return items, total, nil -} - -func (s *ChannelStore) GetForUser(ctx context.Context, channelID, userID string) (*store.ChannelListItem, error) { - row := DB.QueryRowContext(ctx, fmt.Sprintf(` - SELECT %s FROM %s - WHERE c.id = ? AND (c.user_id = ? OR c.id IN ( - SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = ? - )) - `, channelListCols, channelListFrom), channelID, userID, userID) - - var item store.ChannelListItem - var tags, settings string - err := row.Scan( - &item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic, - &item.Description, &item.Model, &item.ProviderConfigID, - &item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID, - &tags, &settings, - &item.MessageCount, st(&item.CreatedAtTime), st(&item.UpdatedAtTime), - ) - if err != nil { - return nil, err - } - item.Tags = ScanArray(tags) - if item.Tags == nil { - item.Tags = []string{} - } - item.Settings = safeJSONString(settings) - item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z") - item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z") - return &item, nil -} - -func (s *ChannelStore) CreateFull(ctx context.Context, ch *models.Channel, folder string, tags []string, aiMode string, - ownerUserID string, dmPartnerIDs []string, defaultModel, defaultConfigID string) error { - ch.ID = store.NewID() - now := time.Now().UTC() - ch.CreatedAt = now - ch.UpdatedAt = now - - if tags == nil { - tags = []string{} - } - - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - tagsJSON := ArrayToJSON(tags) - _, err = tx.ExecContext(ctx, ` - INSERT INTO channels (id, user_id, title, type, description, model, system_prompt, - provider_config_id, folder, folder_id, tags, ai_mode, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ch.ID, ch.UserID, ch.Title, ch.Type, ch.Description, ch.Model, ch.SystemPrompt, - models.NullString(ch.ProviderConfigID), folder, models.NullString(ch.FolderID), - tagsJSON, aiMode, now.Format(timeFmt), now.Format(timeFmt), - ) - if err != nil { - return fmt.Errorf("CreateFull insert channel: %w", err) - } - - // Add owner participant - _, _ = tx.ExecContext(ctx, ` - INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role) - VALUES (?, ?, 'user', ?, 'owner') - ON CONFLICT DO NOTHING`, store.NewID(), ch.ID, ownerUserID) - - // Add DM partner participants - for _, pid := range dmPartnerIDs { - if pid == ownerUserID { - continue - } - _, _ = tx.ExecContext(ctx, ` - INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role) - VALUES (?, ?, 'user', ?, 'member') - ON CONFLICT DO NOTHING`, store.NewID(), ch.ID, pid) - } - - // Auto-create channel_model if model specified - if defaultModel != "" { - _, _ = tx.ExecContext(ctx, ` - INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, is_default) - VALUES (?, ?, ?, ?, 1) - ON CONFLICT DO NOTHING`, store.NewID(), ch.ID, defaultModel, models.NullString(&defaultConfigID)) - } - - return tx.Commit() -} - -func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error { - _, err := DB.ExecContext(ctx, - `UPDATE channels SET settings = json_patch(COALESCE(settings, '{}'), ?) WHERE id = ?`, - string(settingsJSON), channelID) - return err -} - -// ── Monitoring (v0.35.0) ──────────────────── - -func (s *ChannelStore) ListByType(ctx context.Context, channelType string) ([]models.Channel, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, title, COALESCE(description, ''), type, - team_id, created_at - FROM channels WHERE type = ? - ORDER BY created_at DESC`, channelType) - if err != nil { - return nil, err - } - defer rows.Close() - var result []models.Channel - for rows.Next() { - var ch models.Channel - if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description, - &ch.Type, &ch.TeamID, st(&ch.CreatedAt)); err != nil { - return nil, err - } - result = append(result, ch) - } - return result, rows.Err() -} - -// ── CS7b helpers ──────────────────────────── - -func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) { - var result []store.ChannelListItem - for rows.Next() { - var item store.ChannelListItem - var tags, settings string - err := rows.Scan( - &item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic, - &item.Description, &item.Model, &item.ProviderConfigID, - &item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID, - &tags, &settings, - &item.MessageCount, st(&item.CreatedAtTime), st(&item.UpdatedAtTime), - ) - if err != nil { - return nil, err - } - item.Tags = ScanArray(tags) - if item.Tags == nil { - item.Tags = []string{} - } - item.Settings = safeJSONString(settings) - item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z") - item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z") - result = append(result, item) - } - return result, rows.Err() -} - -func safeJSONString(s string) json.RawMessage { - if s == "" || s == "null" { - return json.RawMessage("{}") - } - return json.RawMessage(s) -} diff --git a/server/store/sqlite/channel_helpers.go b/server/store/sqlite/channel_helpers.go deleted file mode 100644 index bda0e91..0000000 --- a/server/store/sqlite/channel_helpers.go +++ /dev/null @@ -1,143 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" -) - -// ── Single-field helpers (v0.29.0) ────────────────────────────────────── - -func (s *ChannelStore) GetAIMode(ctx context.Context, channelID string) (string, error) { - var aiMode string - err := DB.QueryRowContext(ctx, ` - SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = ? - `, channelID).Scan(&aiMode) - if err != nil { - return "auto", err - } - return aiMode, nil -} - -func (s *ChannelStore) GetTypeAndTeamID(ctx context.Context, channelID string) (string, *string, error) { - var channelType string - var teamID *string - err := DB.QueryRowContext(ctx, ` - SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = ? - `, channelID).Scan(&channelType, &teamID) - return channelType, teamID, err -} - -func (s *ChannelStore) GetSystemPrompt(ctx context.Context, channelID string) (*string, error) { - var prompt *string - err := DB.QueryRowContext(ctx, ` - SELECT system_prompt FROM channels WHERE id = ? - `, channelID).Scan(&prompt) - if err == sql.ErrNoRows { - return nil, nil - } - return prompt, err -} - -func (s *ChannelStore) GetDefaultModel(ctx context.Context, channelID string) (*string, error) { - var model *string - err := DB.QueryRowContext(ctx, ` - SELECT model FROM channels WHERE id = ? - `, channelID).Scan(&model) - if err == sql.ErrNoRows { - return nil, nil - } - return model, err -} - -func (s *ChannelStore) TouchUpdatedAt(ctx context.Context, channelID string) error { - _, err := DB.ExecContext(ctx, `UPDATE channels SET updated_at = datetime('now') WHERE id = ?`, channelID) - return err -} - -func (s *ChannelStore) ListUserParticipantIDs(ctx context.Context, channelID, excludeUserID string) ([]string, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT participant_id FROM channel_participants - WHERE channel_id = ? AND participant_type = 'user' AND participant_id != ? - `, channelID, excludeUserID) - 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 = []string{} - } - return ids, rows.Err() -} - -func (s *ChannelStore) ListPersonaParticipantIDs(ctx context.Context, channelID string) ([]string, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT participant_id FROM channel_participants - WHERE channel_id = ? AND participant_type = 'persona' - ORDER BY created_at - `, channelID) - 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 = []string{} - } - return ids, rows.Err() -} - -func (s *ChannelStore) GetLeaderPersonaID(ctx context.Context, channelID string) (string, error) { - var leaderID string - err := DB.QueryRowContext(ctx, ` - SELECT cp.participant_id - FROM channel_participants cp - JOIN persona_group_members pgm ON pgm.persona_id = cp.participant_id - WHERE cp.channel_id = ? - AND cp.participant_type = 'persona' - AND pgm.is_leader = 1 - LIMIT 1 - `, channelID).Scan(&leaderID) - if err == nil && leaderID != "" { - return leaderID, nil - } - - err = DB.QueryRowContext(ctx, ` - SELECT participant_id FROM channel_participants - WHERE channel_id = ? AND participant_type = 'persona' - ORDER BY created_at LIMIT 1 - `, channelID).Scan(&leaderID) - if err == sql.ErrNoRows { - return "", nil - } - return leaderID, err -} - -func (s *ChannelStore) GetWorkflowInfo(ctx context.Context, channelID string) (*string, int, error) { - var workflowID *string - var currentStage int - err := DB.QueryRowContext(ctx, ` - SELECT workflow_id, COALESCE(current_stage, 0) - FROM channels WHERE id = ? AND type = 'workflow' - `, channelID).Scan(&workflowID, ¤tStage) - if err == sql.ErrNoRows { - return nil, 0, nil - } - return workflowID, currentStage, err -} diff --git a/server/store/sqlite/export.go b/server/store/sqlite/export.go deleted file mode 100644 index c536bfe..0000000 --- a/server/store/sqlite/export.go +++ /dev/null @@ -1,1515 +0,0 @@ -package sqlite - -// export.go — v0.34.0 CS0 -// -// ExportStore: batch-read queries for data export and GDPR operations. -// SQLite dialect — uses ? placeholders, st() for time scanning, ScanArray for JSON arrays. - -import ( - "context" - "database/sql" - "fmt" - "strings" - "time" - - "switchboard-core/models" -) - -// ExportStore implements store.ExportStore for SQLite. -type ExportStore struct{} - -// NewExportStore creates a SQLite ExportStore. -func NewExportStore() *ExportStore { return &ExportStore{} } - -// ── helpers ────────────────────────────────── - -// sqlitePlaceholders builds "?,?,...,?" and a matching []any slice for IN queries. -func sqlitePlaceholders(ids []string) (string, []any) { - ph := make([]string, len(ids)) - args := make([]any, len(ids)) - for i, id := range ids { - ph[i] = "?" - args[i] = id - } - return strings.Join(ph, ","), args -} - -// ── User-scoped export ─────────────────────── - -func (s *ExportStore) UserChannels(ctx context.Context, userID string) ([]models.Channel, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, title, description, type, model, system_prompt, - provider_config_id, is_archived, is_pinned, folder_id, - team_id, project_id, workspace_id, settings, allow_anonymous, - created_at, updated_at - FROM channels WHERE user_id = ? - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user channels: %w", err) - } - defer rows.Close() - var out []models.Channel - for rows.Next() { - var ch models.Channel - if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description, - &ch.Type, &ch.Model, &ch.SystemPrompt, &ch.ProviderConfigID, - &ch.IsArchived, &ch.IsPinned, &ch.FolderID, &ch.TeamID, - &ch.ProjectID, &ch.WorkspaceID, &ch.Settings, &ch.AllowAnonymous, - st(&ch.CreatedAt), st(&ch.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan channel: %w", err) - } - out = append(out, ch) - } - return out, rows.Err() -} - -func (s *ExportStore) UserMessages(ctx context.Context, channelIDs []string) ([]models.Message, error) { - if len(channelIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(channelIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, channel_id, role, content, model, tokens_used, - tool_calls, metadata, parent_id, sibling_index, - participant_type, participant_id, provider_config_id, - created_at, updated_at - FROM messages - WHERE channel_id IN (%s) AND deleted_at IS NULL - ORDER BY created_at ASC`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: user messages: %w", err) - } - defer rows.Close() - var out []models.Message - for rows.Next() { - var m models.Message - if err := rows.Scan(&m.ID, &m.ChannelID, &m.Role, &m.Content, - &m.Model, &m.TokensUsed, &m.ToolCalls, &m.Metadata, - &m.ParentID, &m.SiblingIndex, &m.ParticipantType, - &m.ParticipantID, &m.ProviderConfigID, - st(&m.CreatedAt), st(&m.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan message: %w", err) - } - out = append(out, m) - } - return out, rows.Err() -} - -func (s *ExportStore) UserChannelParticipants(ctx context.Context, channelIDs []string) ([]models.ChannelParticipant, error) { - if len(channelIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(channelIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, channel_id, participant_type, participant_id, role, - display_name, avatar_url, joined_at, last_read_at - FROM channel_participants WHERE channel_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: channel participants: %w", err) - } - defer rows.Close() - var out []models.ChannelParticipant - for rows.Next() { - var cp models.ChannelParticipant - if err := rows.Scan(&cp.ID, &cp.ChannelID, &cp.ParticipantType, - &cp.ParticipantID, &cp.Role, &cp.DisplayName, &cp.AvatarURL, - st(&cp.JoinedAt), st(&cp.LastReadAt)); err != nil { - return nil, fmt.Errorf("export: scan participant: %w", err) - } - out = append(out, cp) - } - return out, rows.Err() -} - -func (s *ExportStore) UserChannelModels(ctx context.Context, channelIDs []string) ([]models.ChannelModel, error) { - if len(channelIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(channelIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, channel_id, model_id, provider_config_id, persona_id, - handle, display_name, system_prompt, is_default - FROM channel_models WHERE channel_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: channel models: %w", err) - } - defer rows.Close() - var out []models.ChannelModel - for rows.Next() { - var cm models.ChannelModel - if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, - &cm.ProviderConfigID, &cm.PersonaID, &cm.Handle, - &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil { - return nil, fmt.Errorf("export: scan channel model: %w", err) - } - out = append(out, cm) - } - return out, rows.Err() -} - -func (s *ExportStore) UserChannelCursors(ctx context.Context, userID string, channelIDs []string) ([]models.ChannelCursor, error) { - if len(channelIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(channelIDs) - args = append(args, userID) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, channel_id, user_id, active_leaf_id, updated_at - FROM channel_cursors - WHERE channel_id IN (%s) AND user_id = ?`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: channel cursors: %w", err) - } - defer rows.Close() - var out []models.ChannelCursor - for rows.Next() { - var cc models.ChannelCursor - if err := rows.Scan(&cc.ID, &cc.ChannelID, &cc.UserID, - &cc.ActiveLeafID, st(&cc.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan cursor: %w", err) - } - out = append(out, cc) - } - return out, rows.Err() -} - -func (s *ExportStore) UserNotes(ctx context.Context, userID string) ([]models.Note, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, title, content, folder_path, tags, - metadata, source_channel_id, source_message_id, team_id, - created_at, updated_at - FROM notes WHERE user_id = ? - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user notes: %w", err) - } - defer rows.Close() - var out []models.Note - for rows.Next() { - var n models.Note - var tagsJSON, metadataJSON string - var sourceChannelID, sourceMessageID, teamID sql.NullString - if err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, - &n.FolderPath, &tagsJSON, &metadataJSON, - &sourceChannelID, &sourceMessageID, &teamID, - st(&n.CreatedAt), st(&n.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan note: %w", err) - } - n.Tags = ScanArray(tagsJSON) - n.SourceChannelID = NullableStringPtr(sourceChannelID) - n.SourceMessageID = NullableStringPtr(sourceMessageID) - n.TeamID = NullableStringPtr(teamID) - out = append(out, n) - } - return out, rows.Err() -} - -func (s *ExportStore) UserNoteLinks(ctx context.Context, noteIDs []string) ([]models.ExportNoteLink, error) { - if len(noteIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(noteIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT source_note_id, target_note_id, target_title, - COALESCE(display_text, ''), is_transclusion - FROM note_links WHERE source_note_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: note links: %w", err) - } - defer rows.Close() - var out []models.ExportNoteLink - for rows.Next() { - var nl models.ExportNoteLink - if err := rows.Scan(&nl.SourceNoteID, &nl.TargetNoteID, - &nl.TargetTitle, &nl.DisplayText, &nl.IsTransclusion); err != nil { - return nil, fmt.Errorf("export: scan note link: %w", err) - } - out = append(out, nl) - } - return out, rows.Err() -} - -func (s *ExportStore) UserMemories(ctx context.Context, userID string) ([]models.Memory, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories - WHERE owner_id = ? AND scope = 'user' AND status = 'active' - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user memories: %w", err) - } - defer rows.Close() - var out []models.Memory - for rows.Next() { - var m models.Memory - if err := rows.Scan(&m.ID, &m.Scope, &m.OwnerID, &m.UserID, - &m.Key, &m.Value, &m.SourceChannelID, &m.Confidence, - &m.Status, st(&m.CreatedAt), st(&m.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan memory: %w", err) - } - out = append(out, m) - } - return out, rows.Err() -} - -func (s *ExportStore) UserProjects(ctx context.Context, userID string) ([]models.Project, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, description, color, icon, scope, owner_id, - team_id, is_archived, workspace_id, settings, created_at, updated_at - FROM projects WHERE owner_id = ? AND scope = 'personal' - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user projects: %w", err) - } - defer rows.Close() - var out []models.Project - for rows.Next() { - var p models.Project - if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Color, - &p.Icon, &p.Scope, &p.OwnerID, &p.TeamID, &p.IsArchived, - &p.WorkspaceID, &p.Settings, st(&p.CreatedAt), st(&p.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan project: %w", err) - } - out = append(out, p) - } - return out, rows.Err() -} - -func (s *ExportStore) UserProjectChannels(ctx context.Context, projectIDs []string) ([]models.ProjectChannel, error) { - if len(projectIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(projectIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT project_id, channel_id, position, folder, added_at - FROM project_channels WHERE project_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: project channels: %w", err) - } - defer rows.Close() - var out []models.ProjectChannel - for rows.Next() { - var pc models.ProjectChannel - if err := rows.Scan(&pc.ProjectID, &pc.ChannelID, &pc.Position, - &pc.Folder, &pc.AddedAt); err != nil { - return nil, fmt.Errorf("export: scan project channel: %w", err) - } - out = append(out, pc) - } - return out, rows.Err() -} - -func (s *ExportStore) UserProjectKBs(ctx context.Context, projectIDs []string) ([]models.ProjectKB, error) { - if len(projectIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(projectIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT project_id, kb_id, auto_search, added_at - FROM project_knowledge_bases WHERE project_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: project KBs: %w", err) - } - defer rows.Close() - var out []models.ProjectKB - for rows.Next() { - var pk models.ProjectKB - if err := rows.Scan(&pk.ProjectID, &pk.KBID, &pk.AutoSearch, &pk.AddedAt); err != nil { - return nil, fmt.Errorf("export: scan project KB: %w", err) - } - out = append(out, pk) - } - return out, rows.Err() -} - -func (s *ExportStore) UserProjectNotes(ctx context.Context, projectIDs []string) ([]models.ProjectNote, error) { - if len(projectIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(projectIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT project_id, note_id, added_at - FROM project_notes WHERE project_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: project notes: %w", err) - } - defer rows.Close() - var out []models.ProjectNote - for rows.Next() { - var pn models.ProjectNote - if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt); err != nil { - return nil, fmt.Errorf("export: scan project note: %w", err) - } - out = append(out, pn) - } - return out, rows.Err() -} - -func (s *ExportStore) UserWorkspaces(ctx context.Context, userID string) ([]models.Workspace, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, owner_type, owner_id, name, root_path, max_bytes, status, - indexing_enabled, git_remote_url, git_branch, git_credential_id, - git_last_sync, created_at, updated_at - FROM workspaces WHERE owner_type = 'user' AND owner_id = ? - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user workspaces: %w", err) - } - defer rows.Close() - var out []models.Workspace - for rows.Next() { - var w models.Workspace - if err := rows.Scan(&w.ID, &w.OwnerType, &w.OwnerID, &w.Name, - &w.RootPath, &w.MaxBytes, &w.Status, &w.IndexingEnabled, - &w.GitRemoteURL, &w.GitBranch, &w.GitCredentialID, - stN(&w.GitLastSync), st(&w.CreatedAt), st(&w.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan workspace: %w", err) - } - out = append(out, w) - } - return out, rows.Err() -} - -func (s *ExportStore) UserWorkspaceFiles(ctx context.Context, workspaceIDs []string) ([]models.WorkspaceFile, error) { - if len(workspaceIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(workspaceIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, workspace_id, path, is_directory, content_type, - size_bytes, sha256, index_status, chunk_count, - created_at, updated_at - FROM workspace_files WHERE workspace_id IN (%s) - ORDER BY path ASC`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: workspace files: %w", err) - } - defer rows.Close() - var out []models.WorkspaceFile - for rows.Next() { - var wf models.WorkspaceFile - if err := rows.Scan(&wf.ID, &wf.WorkspaceID, &wf.Path, - &wf.IsDirectory, &wf.ContentType, &wf.SizeBytes, - &wf.SHA256, &wf.IndexStatus, &wf.ChunkCount, - st(&wf.CreatedAt), st(&wf.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan workspace file: %w", err) - } - out = append(out, wf) - } - return out, rows.Err() -} - -func (s *ExportStore) UserFiles(ctx context.Context, userID string) ([]models.File, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, channel_id, message_id, user_id, project_id, - origin, filename, content_type, size_bytes, storage_key, - display_hint, extracted_text, metadata, created_at, updated_at - FROM files WHERE user_id = ? - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user files: %w", err) - } - defer rows.Close() - var out []models.File - for rows.Next() { - var f models.File - if err := rows.Scan(&f.ID, &f.ChannelID, &f.MessageID, &f.UserID, - &f.ProjectID, &f.Origin, &f.Filename, &f.ContentType, - &f.SizeBytes, &f.StorageKey, &f.DisplayHint, - &f.ExtractedText, &f.Metadata, st(&f.CreatedAt), st(&f.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan file: %w", err) - } - out = append(out, f) - } - return out, rows.Err() -} - -func (s *ExportStore) UserFolders(ctx context.Context, userID string) ([]models.Folder, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, name, parent_id, sort_order, created_at, updated_at - FROM folders WHERE user_id = ? - ORDER BY sort_order ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: user folders: %w", err) - } - defer rows.Close() - var out []models.Folder - for rows.Next() { - var f models.Folder - if err := rows.Scan(&f.ID, &f.UserID, &f.Name, &f.ParentID, - &f.SortOrder, st(&f.CreatedAt), st(&f.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan folder: %w", err) - } - out = append(out, f) - } - return out, rows.Err() -} - -func (s *ExportStore) UserModelSettings(ctx context.Context, userID string) ([]models.UserModelSetting, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, model_id, provider_config_id, hidden, - preferred_temperature, preferred_max_tokens, sort_order, - created_at, updated_at - FROM user_model_settings WHERE user_id = ?`, userID) - if err != nil { - return nil, fmt.Errorf("export: user model settings: %w", err) - } - defer rows.Close() - var out []models.UserModelSetting - for rows.Next() { - var ums models.UserModelSetting - if err := rows.Scan(&ums.ID, &ums.UserID, &ums.ModelID, &ums.ProviderConfigID, - &ums.Hidden, &ums.PreferredTemperature, &ums.PreferredMaxTokens, - &ums.SortOrder, st(&ums.CreatedAt), st(&ums.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan user model setting: %w", err) - } - out = append(out, ums) - } - return out, rows.Err() -} - -func (s *ExportStore) UserNotifPrefs(ctx context.Context, userID string) ([]models.NotificationPreference, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, type, in_app, email - FROM notification_preferences WHERE user_id = ?`, userID) - if err != nil { - return nil, fmt.Errorf("export: notification prefs: %w", err) - } - defer rows.Close() - var out []models.NotificationPreference - for rows.Next() { - var np models.NotificationPreference - if err := rows.Scan(&np.ID, &np.UserID, &np.Type, &np.InApp, &np.Email); err != nil { - return nil, fmt.Errorf("export: scan notif pref: %w", err) - } - out = append(out, np) - } - return out, rows.Err() -} - -func (s *ExportStore) UserUsageEntries(ctx context.Context, userID string) ([]models.UsageEntry, error) { - cutoff := time.Now().AddDate(0, 0, -90).Format(timeFmt) - rows, err := DB.QueryContext(ctx, ` - SELECT 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, routing_decision, created_at - FROM usage_log - WHERE user_id = ? AND created_at >= ? - ORDER BY created_at ASC`, userID, cutoff) - if err != nil { - return nil, fmt.Errorf("export: usage entries: %w", err) - } - defer rows.Close() - var out []models.UsageEntry - for rows.Next() { - var ue models.UsageEntry - if err := rows.Scan(&ue.ID, &ue.ChannelID, &ue.UserID, - &ue.ProviderConfigID, &ue.ProviderScope, &ue.ModelID, - &ue.Role, &ue.PromptTokens, &ue.CompletionTokens, - &ue.CacheCreationTokens, &ue.CacheReadTokens, - &ue.CostInput, &ue.CostOutput, &ue.RoutingDecision, - st(&ue.CreatedAt)); err != nil { - return nil, fmt.Errorf("export: scan usage entry: %w", err) - } - out = append(out, ue) - } - return out, rows.Err() -} - -func (s *ExportStore) UserPersonaGroups(ctx context.Context, userID string) ([]models.PersonaGroup, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, description, owner_id, scope, team_id, - created_at, updated_at - FROM persona_groups WHERE owner_id = ? - ORDER BY created_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("export: persona groups: %w", err) - } - defer rows.Close() - var out []models.PersonaGroup - for rows.Next() { - var pg models.PersonaGroup - if err := rows.Scan(&pg.ID, &pg.Name, &pg.Description, &pg.OwnerID, - &pg.Scope, &pg.TeamID, st(&pg.CreatedAt), st(&pg.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan persona group: %w", err) - } - out = append(out, pg) - } - return out, rows.Err() -} - -func (s *ExportStore) UserPersonaGroupMembers(ctx context.Context, groupIDs []string) ([]models.PersonaGroupMember, error) { - if len(groupIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(groupIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, group_id, persona_id, is_leader, sort_order - FROM persona_group_members WHERE group_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: persona group members: %w", err) - } - defer rows.Close() - var out []models.PersonaGroupMember - for rows.Next() { - var pgm models.PersonaGroupMember - if err := rows.Scan(&pgm.ID, &pgm.GroupID, &pgm.PersonaID, - &pgm.IsLeader, &pgm.SortOrder); err != nil { - return nil, fmt.Errorf("export: scan persona group member: %w", err) - } - out = append(out, pgm) - } - return out, rows.Err() -} - -// ── Team-scoped export ─────────────────────── - -func (s *ExportStore) TeamChannels(ctx context.Context, teamID string) ([]models.Channel, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, title, description, type, model, system_prompt, - provider_config_id, is_archived, is_pinned, folder_id, - team_id, project_id, workspace_id, settings, allow_anonymous, - created_at, updated_at - FROM channels WHERE team_id = ? - ORDER BY created_at ASC`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team channels: %w", err) - } - defer rows.Close() - var out []models.Channel - for rows.Next() { - var ch models.Channel - if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description, - &ch.Type, &ch.Model, &ch.SystemPrompt, &ch.ProviderConfigID, - &ch.IsArchived, &ch.IsPinned, &ch.FolderID, &ch.TeamID, - &ch.ProjectID, &ch.WorkspaceID, &ch.Settings, &ch.AllowAnonymous, - st(&ch.CreatedAt), st(&ch.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan team channel: %w", err) - } - out = append(out, ch) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamMembers(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, u.display_name, u.username, u.role - FROM team_members tm - JOIN users u ON u.id = tm.user_id - WHERE tm.team_id = ?`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team members: %w", err) - } - defer rows.Close() - var out []models.TeamMember - for rows.Next() { - var tm models.TeamMember - if err := rows.Scan(&tm.ID, &tm.TeamID, &tm.UserID, &tm.Role, - &tm.JoinedAt, &tm.Email, &tm.DisplayName, &tm.Username, - &tm.UserRole); err != nil { - return nil, fmt.Errorf("export: scan team member: %w", err) - } - out = append(out, tm) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamPersonas(ctx context.Context, teamID string) ([]models.Persona, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, handle, 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, - memory_enabled, memory_extraction_prompt, - created_at, updated_at - FROM personas WHERE scope = 'team' AND owner_id = ? - ORDER BY created_at ASC`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team personas: %w", err) - } - defer rows.Close() - var out []models.Persona - for rows.Next() { - var p models.Persona - if err := rows.Scan(&p.ID, &p.Name, &p.Handle, &p.Description, - &p.Icon, &p.Avatar, &p.BaseModelID, &p.ProviderConfigID, - &p.SystemPrompt, &p.Temperature, &p.MaxTokens, - &p.ThinkingBudget, &p.TopP, &p.Scope, &p.OwnerID, - &p.CreatedBy, &p.IsActive, &p.IsShared, - &p.MemoryEnabled, &p.MemoryExtractionPrompt, - st(&p.CreatedAt), st(&p.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan persona: %w", err) - } - out = append(out, p) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamPersonaKBs(ctx context.Context, personaIDs []string) ([]models.PersonaKB, error) { - if len(personaIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(personaIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT persona_id, kb_id, auto_search, added_at - FROM persona_knowledge_bases WHERE persona_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: persona KBs: %w", err) - } - defer rows.Close() - var out []models.PersonaKB - for rows.Next() { - var pk models.PersonaKB - if err := rows.Scan(&pk.PersonaID, &pk.KBID, &pk.AutoSearch, st(&pk.AddedAt)); err != nil { - return nil, fmt.Errorf("export: scan persona KB: %w", err) - } - out = append(out, pk) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamKnowledgeBases(ctx context.Context, teamID string) ([]models.KnowledgeBase, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, description, scope, owner_id, team_id, - embedding_config, document_count, chunk_count, total_bytes, - discoverable, status, created_at, updated_at - FROM knowledge_bases WHERE team_id = ? - ORDER BY created_at ASC`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team KBs: %w", err) - } - defer rows.Close() - var out []models.KnowledgeBase - for rows.Next() { - var kb models.KnowledgeBase - if err := rows.Scan(&kb.ID, &kb.Name, &kb.Description, &kb.Scope, - &kb.OwnerID, &kb.TeamID, &kb.EmbeddingConfig, &kb.DocumentCount, - &kb.ChunkCount, &kb.TotalBytes, &kb.Discoverable, &kb.Status, - st(&kb.CreatedAt), st(&kb.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan KB: %w", err) - } - out = append(out, kb) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamKBDocuments(ctx context.Context, kbIDs []string) ([]models.KBDocument, error) { - if len(kbIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(kbIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - 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 IN (%s) - ORDER BY created_at ASC`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: KB documents: %w", err) - } - defer rows.Close() - var out []models.KBDocument - for rows.Next() { - var d models.KBDocument - if err := rows.Scan(&d.ID, &d.KBID, &d.Filename, &d.ContentType, - &d.SizeBytes, &d.StorageKey, &d.ChunkCount, &d.Status, - &d.Error, &d.UploadedBy, st(&d.CreatedAt), st(&d.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan KB document: %w", err) - } - out = append(out, d) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamWorkflows(ctx context.Context, teamID string) ([]models.Workflow, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, team_id, name, slug, description, branding, - entry_mode, is_active, version, on_complete, retention, - webhook_url, webhook_secret, created_by, created_at, updated_at - FROM workflows WHERE team_id = ? - ORDER BY created_at ASC`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team workflows: %w", err) - } - defer rows.Close() - var out []models.Workflow - for rows.Next() { - var w models.Workflow - if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, - &w.Description, &w.Branding, &w.EntryMode, &w.IsActive, - &w.Version, &w.OnComplete, &w.Retention, &w.WebhookURL, - &w.WebhookSecret, &w.CreatedBy, st(&w.CreatedAt), st(&w.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan workflow: %w", err) - } - out = append(out, w) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamWorkflowVersions(ctx context.Context, workflowIDs []string) ([]models.WorkflowVersion, error) { - if len(workflowIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(workflowIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, workflow_id, version_number, snapshot, created_at - FROM workflow_versions WHERE workflow_id IN (%s) - ORDER BY version_number ASC`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: workflow versions: %w", err) - } - defer rows.Close() - var out []models.WorkflowVersion - for rows.Next() { - var wv models.WorkflowVersion - if err := rows.Scan(&wv.ID, &wv.WorkflowID, &wv.VersionNumber, - &wv.Snapshot, st(&wv.CreatedAt)); err != nil { - return nil, fmt.Errorf("export: scan workflow version: %w", err) - } - out = append(out, wv) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamWorkflowStages(ctx context.Context, workflowIDs []string) ([]models.WorkflowStage, error) { - if len(workflowIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(workflowIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, workflow_id, ordinal, name, persona_id, - assignment_team_id, form_template, stage_mode, - history_mode, auto_transition, transition_rules, - surface_pkg_id, created_at - FROM workflow_stages WHERE workflow_id IN (%s) - ORDER BY ordinal ASC`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: workflow stages: %w", err) - } - defer rows.Close() - var out []models.WorkflowStage - for rows.Next() { - var ws models.WorkflowStage - if err := rows.Scan(&ws.ID, &ws.WorkflowID, &ws.Ordinal, &ws.Name, - &ws.PersonaID, &ws.AssignmentTeamID, &ws.FormTemplate, - &ws.StageMode, &ws.HistoryMode, &ws.AutoTransition, - &ws.TransitionRules, &ws.SurfacePkgID, st(&ws.CreatedAt)); err != nil { - return nil, fmt.Errorf("export: scan workflow stage: %w", err) - } - out = append(out, ws) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamGroups(ctx context.Context, teamID string) ([]models.Group, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, description, scope, team_id, created_by, source, - permissions, token_budget_daily, token_budget_monthly, - allowed_models, created_at, updated_at - FROM groups WHERE team_id = ? - ORDER BY created_at ASC`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team groups: %w", err) - } - defer rows.Close() - var out []models.Group - for rows.Next() { - var g models.Group - var permJSON, modelsJSON string - if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, - &g.TeamID, &g.CreatedBy, &g.Source, - &permJSON, &g.TokenBudgetDaily, - &g.TokenBudgetMonthly, &modelsJSON, - st(&g.CreatedAt), st(&g.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan group: %w", err) - } - g.Permissions = ScanArray(permJSON) - g.AllowedModels = ScanArray(modelsJSON) - out = append(out, g) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamGroupMembers(ctx context.Context, groupIDs []string) ([]models.GroupMember, error) { - if len(groupIDs) == 0 { - return nil, nil - } - ph, args := sqlitePlaceholders(groupIDs) - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, group_id, user_id, added_by, added_at - FROM group_members WHERE group_id IN (%s)`, ph), args...) - if err != nil { - return nil, fmt.Errorf("export: group members: %w", err) - } - defer rows.Close() - var out []models.GroupMember - for rows.Next() { - var gm models.GroupMember - if err := rows.Scan(&gm.ID, &gm.GroupID, &gm.UserID, &gm.AddedBy, - st(&gm.AddedAt)); err != nil { - return nil, fmt.Errorf("export: scan group member: %w", err) - } - out = append(out, gm) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamResourceGrants(ctx context.Context, teamID string) ([]models.ResourceGrant, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT rg.id, rg.resource_type, rg.resource_id, rg.grant_scope, - rg.granted_groups, rg.created_by, rg.created_at, rg.updated_at - FROM resource_grants rg - WHERE rg.resource_id IN ( - SELECT id FROM personas WHERE scope = 'team' AND owner_id = ? - UNION ALL - SELECT id FROM knowledge_bases WHERE team_id = ? - )`, teamID, teamID) - if err != nil { - return nil, fmt.Errorf("export: team resource grants: %w", err) - } - defer rows.Close() - var out []models.ResourceGrant - for rows.Next() { - var rg models.ResourceGrant - var groupsJSON string - if err := rows.Scan(&rg.ID, &rg.ResourceType, &rg.ResourceID, - &rg.GrantScope, &groupsJSON, - &rg.CreatedBy, st(&rg.CreatedAt), st(&rg.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan resource grant: %w", err) - } - rg.GrantedGroups = ScanArray(groupsJSON) - out = append(out, rg) - } - return out, rows.Err() -} - -func (s *ExportStore) TeamProjects(ctx context.Context, teamID string) ([]models.Project, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, description, color, icon, scope, owner_id, - team_id, is_archived, workspace_id, settings, created_at, updated_at - FROM projects WHERE team_id = ? - ORDER BY created_at ASC`, teamID) - if err != nil { - return nil, fmt.Errorf("export: team projects: %w", err) - } - defer rows.Close() - var out []models.Project - for rows.Next() { - var p models.Project - if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Color, - &p.Icon, &p.Scope, &p.OwnerID, &p.TeamID, &p.IsArchived, - &p.WorkspaceID, &p.Settings, st(&p.CreatedAt), st(&p.UpdatedAt)); err != nil { - return nil, fmt.Errorf("export: scan project: %w", err) - } - out = append(out, p) - } - return out, rows.Err() -} - -// ── GDPR ───────────────────────────────────── - -func (s *ExportStore) CountActiveAdmins(ctx context.Context) (int, error) { - var count int - err := DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND is_active = 1`).Scan(&count) - return count, err -} - -func (s *ExportStore) AnonymizeUser(ctx context.Context, userID string, anonHash string) error { - _, err := DB.ExecContext(ctx, ` - UPDATE users SET - username = ?, - email = ?, - display_name = 'Deleted User', - avatar_url = '', - handle = ?, - settings = NULL, - external_id = NULL, - is_active = 0, - password_hash = '', - updated_at = datetime('now') - WHERE id = ?`, - "deleted-user-"+anonHash, - "deleted-"+anonHash+"@deleted.local", - "deleted-"+anonHash, - userID, - ) - return err -} - -func (s *ExportStore) SoftDeleteUserData(ctx context.Context, userID string) (map[string]int64, error) { - counts := make(map[string]int64) - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return nil, fmt.Errorf("export: begin tx: %w", err) - } - defer tx.Rollback() - - now := time.Now().UTC().Format(timeFmt) - - // Messages in user-owned channels - r, err := tx.ExecContext(ctx, ` - UPDATE messages SET deleted_at = ? - WHERE channel_id IN (SELECT id FROM channels WHERE user_id = ?) - AND deleted_at IS NULL`, now, userID) - if err != nil { - return nil, fmt.Errorf("export: soft-delete owned messages: %w", err) - } - n, _ := r.RowsAffected() - counts["messages_owned"] = n - - // Messages sent by user in other channels - r, err = tx.ExecContext(ctx, ` - UPDATE messages SET deleted_at = ? - WHERE participant_type = 'user' AND participant_id = ? - AND deleted_at IS NULL`, now, userID) - if err != nil { - return nil, fmt.Errorf("export: soft-delete sent messages: %w", err) - } - n, _ = r.RowsAffected() - counts["messages_sent"] = n - - // Archive channels - r, err = tx.ExecContext(ctx, `UPDATE channels SET is_archived = 1 WHERE user_id = ?`, userID) - if err != nil { - return nil, fmt.Errorf("export: archive channels: %w", err) - } - n, _ = r.RowsAffected() - counts["channels"] = n - - // Notes + links - tx.ExecContext(ctx, `DELETE FROM note_links WHERE source_note_id IN (SELECT id FROM notes WHERE user_id = ?)`, userID) - r, err = tx.ExecContext(ctx, `DELETE FROM notes WHERE user_id = ?`, userID) - if err != nil { - return nil, fmt.Errorf("export: delete notes: %w", err) - } - n, _ = r.RowsAffected() - counts["notes"] = n - - // Memories - r, err = tx.ExecContext(ctx, `DELETE FROM memories WHERE owner_id = ? AND scope = 'user'`, userID) - if err != nil { - return nil, fmt.Errorf("export: delete memories: %w", err) - } - n, _ = r.RowsAffected() - counts["memories"] = n - - // Workspace cascade - tx.ExecContext(ctx, `DELETE FROM workspace_chunks WHERE workspace_id IN (SELECT id FROM workspaces WHERE owner_type = 'user' AND owner_id = ?)`, userID) - tx.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id IN (SELECT id FROM workspaces WHERE owner_type = 'user' AND owner_id = ?)`, userID) - r, err = tx.ExecContext(ctx, `DELETE FROM workspaces WHERE owner_type = 'user' AND owner_id = ?`, userID) - if err != nil { - return nil, fmt.Errorf("export: delete workspaces: %w", err) - } - n, _ = r.RowsAffected() - counts["workspaces"] = n - - // Files - r, err = tx.ExecContext(ctx, `DELETE FROM files WHERE user_id = ?`, userID) - if err != nil { - return nil, fmt.Errorf("export: delete files: %w", err) - } - n, _ = r.RowsAffected() - counts["files"] = n - - // Projects cascade - tx.ExecContext(ctx, `DELETE FROM project_notes WHERE project_id IN (SELECT id FROM projects WHERE scope = 'personal' AND owner_id = ?)`, userID) - tx.ExecContext(ctx, `DELETE FROM project_knowledge_bases WHERE project_id IN (SELECT id FROM projects WHERE scope = 'personal' AND owner_id = ?)`, userID) - tx.ExecContext(ctx, `DELETE FROM project_channels WHERE project_id IN (SELECT id FROM projects WHERE scope = 'personal' AND owner_id = ?)`, userID) - r, err = tx.ExecContext(ctx, `DELETE FROM projects WHERE scope = 'personal' AND owner_id = ?`, userID) - if err != nil { - return nil, fmt.Errorf("export: delete projects: %w", err) - } - n, _ = r.RowsAffected() - counts["projects"] = n - - // Folders, settings, prefs, notifications - tx.ExecContext(ctx, `DELETE FROM folders WHERE user_id = ?`, userID) - tx.ExecContext(ctx, `DELETE FROM user_model_settings WHERE user_id = ?`, userID) - tx.ExecContext(ctx, `DELETE FROM notification_preferences WHERE user_id = ?`, userID) - tx.ExecContext(ctx, `DELETE FROM notifications WHERE user_id = ?`, userID) - - // Persona groups - tx.ExecContext(ctx, `DELETE FROM persona_group_members WHERE group_id IN (SELECT id FROM persona_groups WHERE owner_id = ?)`, userID) - tx.ExecContext(ctx, `DELETE FROM persona_groups WHERE owner_id = ?`, userID) - - // Channel participation - tx.ExecContext(ctx, `DELETE FROM channel_participants WHERE participant_type = 'user' AND participant_id = ?`, userID) - tx.ExecContext(ctx, `DELETE FROM channel_cursors WHERE user_id = ?`, userID) - - // Memory extraction log, presence - tx.ExecContext(ctx, `DELETE FROM memory_extraction_log WHERE user_id = ?`, userID) - tx.ExecContext(ctx, `DELETE FROM user_presence WHERE user_id = ?`, userID) - - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("export: commit: %w", err) - } - return counts, nil -} - -func (s *ExportStore) DeleteUserTokens(ctx context.Context, userID string) error { - _, err := DB.ExecContext(ctx, `DELETE FROM refresh_tokens WHERE user_id = ?`, userID) - if err != nil { - return fmt.Errorf("export: delete refresh tokens: %w", err) - } - _, err = DB.ExecContext(ctx, `DELETE FROM ws_tickets WHERE user_id = ?`, userID) - if err != nil { - return fmt.Errorf("export: delete ws tickets: %w", err) - } - return nil -} - -// ── Import (CS1) ───────────────────────────── - -func (s *ExportStore) ImportFolders(ctx context.Context, folders []models.Folder) (int, int, error) { - imported, skipped := 0, 0 - for _, f := range folders { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO folders (id, user_id, name, parent_id, sort_order, created_at, updated_at) - VALUES (?,?,?,?,?,?,?)`, - f.ID, f.UserID, f.Name, f.ParentID, f.SortOrder, - f.CreatedAt.Format(timeFmt), f.UpdatedAt.Format(timeFmt)) - if err != nil { - return imported, skipped, fmt.Errorf("import folder %s: %w", f.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportProjects(ctx context.Context, projects []models.Project) (int, int, error) { - imported, skipped := 0, 0 - for _, p := range projects { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO projects (id, name, description, color, icon, scope, owner_id, - team_id, is_archived, workspace_id, settings, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, - p.ID, p.Name, p.Description, p.Color, p.Icon, p.Scope, p.OwnerID, - p.TeamID, p.IsArchived, p.WorkspaceID, p.Settings, - p.CreatedAt.Format(timeFmt), p.UpdatedAt.Format(timeFmt)) - if err != nil { - return imported, skipped, fmt.Errorf("import project %s: %w", p.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportWorkspaces(ctx context.Context, workspaces []models.Workspace) (int, int, error) { - imported, skipped := 0, 0 - for _, w := range workspaces { - var gitLastSync interface{} - if w.GitLastSync != nil { - t := w.GitLastSync.Format(timeFmt) - gitLastSync = t - } - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status, - indexing_enabled, git_remote_url, git_branch, git_credential_id, - git_last_sync, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath, w.MaxBytes, w.Status, - w.IndexingEnabled, w.GitRemoteURL, w.GitBranch, w.GitCredentialID, - gitLastSync, w.CreatedAt.Format(timeFmt), w.UpdatedAt.Format(timeFmt)) - if err != nil { - return imported, skipped, fmt.Errorf("import workspace %s: %w", w.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportChannels(ctx context.Context, channels []models.Channel) (int, int, error) { - imported, skipped := 0, 0 - for _, ch := range channels { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO channels (id, user_id, title, description, type, model, system_prompt, - provider_config_id, is_archived, is_pinned, folder_id, - team_id, project_id, workspace_id, settings, allow_anonymous, - created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - ch.ID, ch.UserID, ch.Title, ch.Description, ch.Type, ch.Model, ch.SystemPrompt, - ch.ProviderConfigID, ch.IsArchived, ch.IsPinned, ch.FolderID, ch.TeamID, - ch.ProjectID, ch.WorkspaceID, ch.Settings, ch.AllowAnonymous, - ch.CreatedAt.Format(timeFmt), ch.UpdatedAt.Format(timeFmt)) - if err != nil { - return imported, skipped, fmt.Errorf("import channel %s: %w", ch.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportChannelParticipants(ctx context.Context, cps []models.ChannelParticipant) (int, int, error) { - imported, skipped := 0, 0 - for _, cp := range cps { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO channel_participants (id, channel_id, participant_type, participant_id, role, - display_name, avatar_url, joined_at, last_read_at) - VALUES (?,?,?,?,?,?,?,?,?)`, - cp.ID, cp.ChannelID, cp.ParticipantType, cp.ParticipantID, cp.Role, - cp.DisplayName, cp.AvatarURL, - cp.JoinedAt.Format(timeFmt), cp.LastReadAt.Format(timeFmt)) - if err != nil { - return imported, skipped, fmt.Errorf("import participant %s: %w", cp.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportChannelModels(ctx context.Context, cms []models.ChannelModel) (int, int, error) { - imported, skipped := 0, 0 - for _, cm := range cms { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO channel_models (id, channel_id, model_id, provider_config_id, persona_id, - handle, display_name, system_prompt, is_default) - VALUES (?,?,?,?,?,?,?,?,?)`, - cm.ID, cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.PersonaID, - cm.Handle, cm.DisplayName, cm.SystemPrompt, cm.IsDefault) - if err != nil { - return imported, skipped, fmt.Errorf("import channel model %s: %w", cm.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportChannelCursors(ctx context.Context, cursors []models.ChannelCursor) (int, int, error) { - imported, skipped := 0, 0 - for _, cc := range cursors { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO channel_cursors (id, channel_id, user_id, active_leaf_id, updated_at) - VALUES (?,?,?,?,?)`, - cc.ID, cc.ChannelID, cc.UserID, cc.ActiveLeafID, - cc.UpdatedAt.Format(timeFmt)) - if err != nil { - return imported, skipped, fmt.Errorf("import cursor %s: %w", cc.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportMessages(ctx context.Context, msgs []models.Message) (int, int, error) { - imported, skipped := 0, 0 - for _, m := range msgs { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO messages (id, channel_id, role, content, model, tokens_used, - tool_calls, metadata, parent_id, sibling_index, - participant_type, participant_id, provider_config_id, - created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - m.ID, m.ChannelID, m.Role, m.Content, m.Model, m.TokensUsed, - ToJSON(m.ToolCalls), ToJSON(m.Metadata), m.ParentID, m.SiblingIndex, - m.ParticipantType, m.ParticipantID, m.ProviderConfigID, - m.CreatedAt.Format(timeFmt), m.UpdatedAt.Format(timeFmt)) - if err != nil { - return imported, skipped, fmt.Errorf("import message %s: %w", m.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportNotes(ctx context.Context, notes []models.Note) (int, int, error) { - imported, skipped := 0, 0 - for _, n := range notes { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO notes (id, user_id, title, content, folder_path, tags, - metadata, source_channel_id, source_message_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.SourceMessageID), models.NullString(n.TeamID), - n.CreatedAt.Format(timeFmt), n.UpdatedAt.Format(timeFmt)) - if err != nil { - return imported, skipped, fmt.Errorf("import note %s: %w", n.ID, err) - } - nr, _ := r.RowsAffected() - if nr > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportNoteLinks(ctx context.Context, links []models.ExportNoteLink) (int, int, error) { - imported, skipped := 0, 0 - for _, nl := range links { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO note_links (source_note_id, target_note_id, target_title, display_text, is_transclusion) - VALUES (?,?,?,?,?)`, - nl.SourceNoteID, nl.TargetNoteID, nl.TargetTitle, nl.DisplayText, nl.IsTransclusion) - if err != nil { - return imported, skipped, fmt.Errorf("import note link: %w", err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportMemories(ctx context.Context, mems []models.Memory) (int, int, error) { - imported, skipped := 0, 0 - for _, m := range mems { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO memories (id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?)`, - m.ID, m.Scope, m.OwnerID, m.UserID, m.Key, m.Value, - m.SourceChannelID, m.Confidence, m.Status, - m.CreatedAt.Format(timeFmt), m.UpdatedAt.Format(timeFmt)) - if err != nil { - return imported, skipped, fmt.Errorf("import memory %s: %w", m.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportProjectChannels(ctx context.Context, pcs []models.ProjectChannel) (int, int, error) { - imported, skipped := 0, 0 - for _, pc := range pcs { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO project_channels (project_id, channel_id, position, folder, added_at) - VALUES (?,?,?,?,?)`, - pc.ProjectID, pc.ChannelID, pc.Position, pc.Folder, pc.AddedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import project channel: %w", err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportProjectKBs(ctx context.Context, pks []models.ProjectKB) (int, int, error) { - imported, skipped := 0, 0 - for _, pk := range pks { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO project_knowledge_bases (project_id, kb_id, auto_search, added_at) - VALUES (?,?,?,?)`, - pk.ProjectID, pk.KBID, pk.AutoSearch, pk.AddedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import project KB: %w", err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportProjectNotes(ctx context.Context, pns []models.ProjectNote) (int, int, error) { - imported, skipped := 0, 0 - for _, pn := range pns { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO project_notes (project_id, note_id, added_at) - VALUES (?,?,?)`, - pn.ProjectID, pn.NoteID, pn.AddedAt) - if err != nil { - return imported, skipped, fmt.Errorf("import project note: %w", err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportWorkspaceFiles(ctx context.Context, wfs []models.WorkspaceFile) (int, int, error) { - imported, skipped := 0, 0 - for _, wf := range wfs { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO workspace_files (id, workspace_id, path, is_directory, content_type, - size_bytes, sha256, index_status, chunk_count, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?)`, - wf.ID, wf.WorkspaceID, wf.Path, wf.IsDirectory, wf.ContentType, - wf.SizeBytes, wf.SHA256, wf.IndexStatus, wf.ChunkCount, - wf.CreatedAt.Format(timeFmt), wf.UpdatedAt.Format(timeFmt)) - if err != nil { - return imported, skipped, fmt.Errorf("import workspace file %s: %w", wf.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportFiles(ctx context.Context, files []models.File) (int, int, error) { - imported, skipped := 0, 0 - for _, f := range files { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO files (id, channel_id, message_id, user_id, project_id, - origin, filename, content_type, size_bytes, storage_key, - display_hint, extracted_text, metadata, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - f.ID, f.ChannelID, f.MessageID, f.UserID, f.ProjectID, - f.Origin, f.Filename, f.ContentType, f.SizeBytes, f.StorageKey, - f.DisplayHint, f.ExtractedText, f.Metadata, - f.CreatedAt.Format(timeFmt), f.UpdatedAt.Format(timeFmt)) - if err != nil { - return imported, skipped, fmt.Errorf("import file %s: %w", f.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportUserModelSettings(ctx context.Context, ums []models.UserModelSetting) (int, int, error) { - imported, skipped := 0, 0 - for _, um := range ums { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden, - preferred_temperature, preferred_max_tokens, sort_order, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?)`, - um.ID, um.UserID, um.ModelID, um.ProviderConfigID, um.Hidden, - um.PreferredTemperature, um.PreferredMaxTokens, um.SortOrder, - um.CreatedAt.Format(timeFmt), um.UpdatedAt.Format(timeFmt)) - if err != nil { - return imported, skipped, fmt.Errorf("import user model setting %s: %w", um.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportNotifPrefs(ctx context.Context, nps []models.NotificationPreference) (int, int, error) { - imported, skipped := 0, 0 - for _, np := range nps { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO notification_preferences (id, user_id, type, in_app, email) - VALUES (?,?,?,?,?)`, - np.ID, np.UserID, np.Type, np.InApp, np.Email) - if err != nil { - return imported, skipped, fmt.Errorf("import notif pref %s: %w", np.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportPersonaGroups(ctx context.Context, pgs []models.PersonaGroup) (int, int, error) { - imported, skipped := 0, 0 - for _, pg := range pgs { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO persona_groups (id, name, description, owner_id, scope, team_id, - created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?)`, - pg.ID, pg.Name, pg.Description, pg.OwnerID, pg.Scope, pg.TeamID, - pg.CreatedAt.Format(timeFmt), pg.UpdatedAt.Format(timeFmt)) - if err != nil { - return imported, skipped, fmt.Errorf("import persona group %s: %w", pg.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} - -func (s *ExportStore) ImportPersonaGroupMembers(ctx context.Context, pgms []models.PersonaGroupMember) (int, int, error) { - imported, skipped := 0, 0 - for _, pgm := range pgms { - r, err := DB.ExecContext(ctx, ` - INSERT OR IGNORE INTO persona_group_members (id, group_id, persona_id, is_leader, sort_order) - VALUES (?,?,?,?,?)`, - pgm.ID, pgm.GroupID, pgm.PersonaID, pgm.IsLeader, pgm.SortOrder) - if err != nil { - return imported, skipped, fmt.Errorf("import persona group member %s: %w", pgm.ID, err) - } - n, _ := r.RowsAffected() - if n > 0 { - imported++ - } else { - skipped++ - } - } - return imported, skipped, nil -} diff --git a/server/store/sqlite/file.go b/server/store/sqlite/file.go deleted file mode 100644 index b0cdaf2..0000000 --- a/server/store/sqlite/file.go +++ /dev/null @@ -1,265 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "time" - - "switchboard-core/models" - "switchboard-core/store" -) - -// FileStore implements store.FileStore against the `files` table. -// -type FileStore struct{} - -func NewFileStore() *FileStore { return &FileStore{} } - -const fileCols = `id, channel_id, user_id, message_id, project_id, origin, - filename, content_type, size_bytes, storage_key, display_hint, - extracted_text, metadata, created_at, updated_at` - -func scanFile(row interface{ Scan(dest ...interface{}) error }) (*models.File, error) { - var f models.File - var messageID sql.NullString - var projectID sql.NullString - var extractedText sql.NullString - var metadataJSON []byte - - err := row.Scan( - &f.ID, &f.ChannelID, &f.UserID, &messageID, &projectID, &f.Origin, - &f.Filename, &f.ContentType, &f.SizeBytes, - &f.StorageKey, &f.DisplayHint, - &extractedText, &metadataJSON, st(&f.CreatedAt), st(&f.UpdatedAt), - ) - if err != nil { - return nil, err - } - - f.MessageID = NullableStringPtr(messageID) - f.ProjectID = NullableStringPtr(projectID) - if extractedText.Valid { - f.ExtractedText = &extractedText.String - } - if len(metadataJSON) > 0 { - json.Unmarshal(metadataJSON, &f.Metadata) - } - return &f, nil -} - -func (s *FileStore) Create(ctx context.Context, f *models.File) error { - f.ID = store.NewID() - now := time.Now().UTC() - f.CreatedAt = now - f.UpdatedAt = now - if f.Origin == "" { - f.Origin = models.FileOriginUserUpload - } - if f.DisplayHint == "" { - f.DisplayHint = models.FileHintDownload - } - _, err := DB.ExecContext(ctx, ` - INSERT INTO files (id, channel_id, user_id, message_id, project_id, origin, - filename, content_type, size_bytes, storage_key, display_hint, - extracted_text, metadata, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - f.ID, f.ChannelID, f.UserID, models.NullString(f.MessageID), - models.NullString(f.ProjectID), f.Origin, - f.Filename, f.ContentType, f.SizeBytes, - f.StorageKey, f.DisplayHint, models.NullString(f.ExtractedText), - ToJSON(f.Metadata), now.Format(timeFmt), now.Format(timeFmt), - ) - return err -} - -func (s *FileStore) GetByID(ctx context.Context, id string) (*models.File, error) { - row := DB.QueryRowContext(ctx, `SELECT `+fileCols+` FROM files WHERE id = ?`, id) - return scanFile(row) -} - -func (s *FileStore) GetByChannel(ctx context.Context, channelID string, origin string) ([]models.File, error) { - var rows *sql.Rows - var err error - if origin != "" { - rows, err = DB.QueryContext(ctx, - `SELECT `+fileCols+` FROM files WHERE channel_id = ? AND origin = ? ORDER BY created_at`, - channelID, origin) - } else { - rows, err = DB.QueryContext(ctx, - `SELECT `+fileCols+` FROM files WHERE channel_id = ? ORDER BY created_at`, - channelID) - } - if err != nil { - return nil, err - } - defer rows.Close() - - var out []models.File - for rows.Next() { - f, err := scanFile(rows) - if err != nil { - return nil, err - } - out = append(out, *f) - } - return out, rows.Err() -} - -func (s *FileStore) GetByMessage(ctx context.Context, messageID string) ([]models.File, error) { - rows, err := DB.QueryContext(ctx, - `SELECT `+fileCols+` FROM files WHERE message_id = ? ORDER BY created_at`, messageID) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []models.File - for rows.Next() { - f, err := scanFile(rows) - if err != nil { - return nil, err - } - out = append(out, *f) - } - return out, rows.Err() -} - -func (s *FileStore) GetByProject(ctx context.Context, projectID string) ([]models.File, error) { - rows, err := DB.QueryContext(ctx, - `SELECT `+fileCols+` FROM files WHERE project_id = ? ORDER BY created_at`, projectID) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []models.File - for rows.Next() { - f, err := scanFile(rows) - if err != nil { - return nil, err - } - out = append(out, *f) - } - return out, rows.Err() -} - -func (s *FileStore) GetByUser(ctx context.Context, userID string, page, perPage int) ([]models.File, int, error) { - var total int - err := DB.QueryRowContext(ctx, - `SELECT COUNT(*) FROM files WHERE user_id = ?`, userID).Scan(&total) - if err != nil { - return nil, 0, err - } - - offset := (page - 1) * perPage - rows, err := DB.QueryContext(ctx, - `SELECT `+fileCols+` FROM files WHERE user_id = ? - ORDER BY created_at DESC LIMIT ? OFFSET ?`, userID, perPage, offset) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var out []models.File - for rows.Next() { - f, err := scanFile(rows) - if err != nil { - return nil, total, err - } - out = append(out, *f) - } - return out, total, rows.Err() -} - -func (s *FileStore) SetMessageID(ctx context.Context, fileID, messageID string) error { - _, err := DB.ExecContext(ctx, - `UPDATE files SET message_id = ?, updated_at = ? WHERE id = ?`, - messageID, time.Now().UTC().Format(timeFmt), fileID) - return err -} - -func (s *FileStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error { - metaJSON, err := json.Marshal(metadata) - if err != nil { - return err - } - _, err = DB.ExecContext(ctx, - `UPDATE files SET metadata = json_patch(metadata, ?), updated_at = ? WHERE id = ?`, - string(metaJSON), time.Now().UTC().Format(timeFmt), id) - return err -} - -func (s *FileStore) SetExtractedText(ctx context.Context, id string, text string) error { - _, err := DB.ExecContext(ctx, - `UPDATE files SET extracted_text = ?, updated_at = ? WHERE id = ?`, - text, time.Now().UTC().Format(timeFmt), id) - return err -} - -func (s *FileStore) Delete(ctx context.Context, id string) (*models.File, error) { - row := DB.QueryRowContext(ctx, - `DELETE FROM files WHERE id = ? RETURNING `+fileCols, id) - return scanFile(row) -} - -func (s *FileStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) { - rows, err := DB.QueryContext(ctx, - `DELETE FROM files 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 *FileStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) { - var total sql.NullInt64 - err := DB.QueryRowContext(ctx, - `SELECT COALESCE(SUM(size_bytes), 0) FROM files WHERE user_id = ?`, - userID).Scan(&total) - if err != nil { - return 0, err - } - return total.Int64, nil -} - -func (s *FileStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.File, error) { - cutoff := time.Now().Add(-olderThan) - rows, err := DB.QueryContext(ctx, - `SELECT `+fileCols+` FROM files - WHERE message_id IS NULL AND origin = 'user_upload' AND created_at < ? - ORDER BY created_at`, cutoff) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []models.File - for rows.Next() { - f, err := scanFile(rows) - if err != nil { - return nil, err - } - out = append(out, *f) - } - return out, rows.Err() -} - -// ── CS5c additions (v0.29.0) ────────────────────────────────────────── - -func (s *FileStore) UpdateStorageKey(ctx context.Context, id, key string) error { - _, err := DB.ExecContext(ctx, - `UPDATE files SET storage_key = ?, updated_at = datetime('now') WHERE id = ?`, - key, id) - return err -} diff --git a/server/store/sqlite/folder.go b/server/store/sqlite/folder.go deleted file mode 100644 index 4115a9a..0000000 --- a/server/store/sqlite/folder.go +++ /dev/null @@ -1,95 +0,0 @@ -package sqlite - -import ( - "context" - - "switchboard-core/models" - "switchboard-core/store" -) - -type FolderStore struct{} - -func NewFolderStore() *FolderStore { return &FolderStore{} } - -func (s *FolderStore) List(ctx context.Context, userID string) ([]models.Folder, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, name, parent_id, sort_order, created_at, updated_at - FROM folders WHERE user_id = ? - ORDER BY sort_order, name - `, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.Folder - for rows.Next() { - var f models.Folder - if err := rows.Scan(&f.ID, &f.Name, &f.ParentID, &f.SortOrder, - st(&f.CreatedAt), st(&f.UpdatedAt)); err != nil { - continue - } - f.UserID = userID - result = append(result, f) - } - if result == nil { - result = []models.Folder{} - } - return result, rows.Err() -} - -func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error { - f.ID = store.NewID() - _, err := DB.ExecContext(ctx, ` - INSERT INTO folders (id, user_id, name, parent_id, sort_order) - VALUES (?, ?, ?, ?, ?) - `, f.ID, f.UserID, f.Name, f.ParentID, f.SortOrder) - if err != nil { - return err - } - return DB.QueryRowContext(ctx, ` - SELECT name, parent_id, sort_order, created_at, updated_at - FROM folders WHERE id = ? - `, f.ID).Scan(&f.Name, &f.ParentID, &f.SortOrder, st(&f.CreatedAt), st(&f.UpdatedAt)) -} - -func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int, parentID **string) (int64, error) { - if parentID != nil { - res, err := DB.ExecContext(ctx, ` - UPDATE folders - SET name = COALESCE(NULLIF(?, ''), name), - sort_order = COALESCE(?, sort_order), - parent_id = ? - WHERE id = ? AND user_id = ? - `, name, sortOrder, *parentID, folderID, userID) - if err != nil { - return 0, err - } - return res.RowsAffected() - } - res, err := DB.ExecContext(ctx, ` - UPDATE folders - SET name = COALESCE(NULLIF(?, ''), name), - sort_order = COALESCE(?, sort_order) - WHERE id = ? AND user_id = ? - `, name, sortOrder, folderID, userID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *FolderStore) Delete(ctx context.Context, folderID, userID string) (int64, error) { - res, err := DB.ExecContext(ctx, - `DELETE FROM folders WHERE id = ? AND user_id = ?`, folderID, userID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *FolderStore) UnassignChannels(ctx context.Context, folderID, userID string) error { - _, err := DB.ExecContext(ctx, - `UPDATE channels SET folder_id = NULL WHERE folder_id = ? AND user_id = ?`, folderID, userID) - return err -} diff --git a/server/store/sqlite/git_credentials.go b/server/store/sqlite/git_credentials.go deleted file mode 100644 index faf991b..0000000 --- a/server/store/sqlite/git_credentials.go +++ /dev/null @@ -1,87 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/base64" - - "switchboard-core/models" - - "github.com/google/uuid" -) - -// GitCredentialStore implements store.GitCredentialStore for SQLite. -type GitCredentialStore struct{} - -func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredential) error { - if cred.ID == "" { - cred.ID = uuid.NewString() - } - _, err := DB.ExecContext(ctx, ` - INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`, - cred.ID, cred.UserID, cred.Name, cred.AuthType, - base64.StdEncoding.EncodeToString(cred.EncryptedData), - base64.StdEncoding.EncodeToString(cred.Nonce), - cred.PublicKey, cred.Fingerprint, cred.PersonaID, - ) - if err != nil { - return err - } - // Read back created_at - return DB.QueryRowContext(ctx, - `SELECT created_at FROM git_credentials WHERE id = ?`, cred.ID, - ).Scan(st(&cred.CreatedAt)) -} - -func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.GitCredential, error) { - var c models.GitCredential - var encData, nonce string - err := DB.QueryRowContext(ctx, ` - SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at - FROM git_credentials WHERE id = ?`, id, - ).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType, - &encData, &nonce, &c.PublicKey, &c.Fingerprint, &c.PersonaID, st(&c.CreatedAt)) - if err != nil { - return nil, err - } - c.EncryptedData, _ = base64.StdEncoding.DecodeString(encData) - c.Nonce, _ = base64.StdEncoding.DecodeString(nonce) - return &c, nil -} - -func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at - FROM git_credentials WHERE user_id = ? ORDER BY created_at DESC`, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - var creds []models.GitCredential - for rows.Next() { - var c models.GitCredential - var encData, nonce string - if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType, - &encData, &nonce, &c.PublicKey, &c.Fingerprint, &c.PersonaID, st(&c.CreatedAt)); err != nil { - return nil, err - } - c.EncryptedData, _ = base64.StdEncoding.DecodeString(encData) - c.Nonce, _ = base64.StdEncoding.DecodeString(nonce) - creds = append(creds, c) - } - return creds, rows.Err() -} - -func (s *GitCredentialStore) Delete(ctx context.Context, id, userID string) error { - res, err := DB.ExecContext(ctx, `DELETE FROM git_credentials WHERE id = ? AND user_id = ?`, id, userID) - if err != nil { - return err - } - n, _ := res.RowsAffected() - if n == 0 { - return sql.ErrNoRows - } - return nil -} diff --git a/server/store/sqlite/knowledge_bases.go b/server/store/sqlite/knowledge_bases.go deleted file mode 100644 index 33b1458..0000000 --- a/server/store/sqlite/knowledge_bases.go +++ /dev/null @@ -1,627 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "math" - "sort" - "strings" - "time" - - "switchboard-core/models" - "switchboard-core/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) 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), ",") -} diff --git a/server/store/sqlite/memory.go b/server/store/sqlite/memory.go deleted file mode 100644 index 2bc34c6..0000000 --- a/server/store/sqlite/memory.go +++ /dev/null @@ -1,320 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "fmt" - "strings" - - "switchboard-core/models" - "switchboard-core/store" -) - -type MemoryStore struct{} - -func NewMemoryStore() *MemoryStore { return &MemoryStore{} } - -func (s *MemoryStore) Upsert(ctx context.Context, m *models.Memory) error { - if m.ID == "" { - m.ID = store.NewID() - } - if m.Status == "" { - m.Status = models.MemoryStatusActive - } - if m.Confidence == 0 { - m.Confidence = 1.0 - } - - _, err := DB.ExecContext(ctx, ` - INSERT INTO memories (id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (scope, owner_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'), key) - DO UPDATE SET - value = excluded.value, - confidence = excluded.confidence, - status = excluded.status, - source_channel_id = excluded.source_channel_id, - updated_at = datetime('now') - `, m.ID, m.Scope, m.OwnerID, m.UserID, m.Key, m.Value, - m.SourceChannelID, m.Confidence, m.Status) - return err -} - -func (s *MemoryStore) Update(ctx context.Context, m *models.Memory) error { - _, err := DB.ExecContext(ctx, ` - UPDATE memories - SET key = ?, value = ?, confidence = ?, status = ?, - source_channel_id = ?, updated_at = datetime('now') - WHERE id = ? - `, m.Key, m.Value, m.Confidence, m.Status, m.SourceChannelID, m.ID) - return err -} - -func (s *MemoryStore) GetByID(ctx context.Context, id string) (*models.Memory, error) { - m := &models.Memory{} - err := DB.QueryRowContext(ctx, ` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories WHERE id = ? - `, id).Scan( - &m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value, - &m.SourceChannelID, &m.Confidence, &m.Status, - st(&m.CreatedAt), st(&m.UpdatedAt), - ) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("memory not found: %s", id) - } - return m, err -} - -func (s *MemoryStore) List(ctx context.Context, f models.MemoryFilter) ([]models.Memory, error) { - clauses := []string{"scope = ?", "owner_id = ?"} - args := []interface{}{f.Scope, f.OwnerID} - - if f.UserID != nil { - clauses = append(clauses, "user_id = ?") - args = append(args, *f.UserID) - } - - status := f.Status - if status == "" { - status = models.MemoryStatusActive - } - clauses = append(clauses, "status = ?") - args = append(args, status) - - if f.Query != "" { - // SQLite: LIKE-based keyword search (no tsvector) - clauses = append(clauses, "(key || ' ' || value) LIKE ?") - args = append(args, "%"+f.Query+"%") - } - - limit := f.Limit - if limit <= 0 || limit > 200 { - limit = 50 - } - args = append(args, limit) - - query := fmt.Sprintf(` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories - WHERE %s - ORDER BY confidence DESC, updated_at DESC - LIMIT ? - `, strings.Join(clauses, " AND ")) - - rows, err := DB.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - return s.scanMemories(rows) -} - -func (s *MemoryStore) Delete(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, `DELETE FROM memories WHERE id = ?`, id) - return err -} - -func (s *MemoryStore) Archive(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, ` - UPDATE memories SET status = 'archived', updated_at = datetime('now') WHERE id = ? - `, id) - return err -} - -func (s *MemoryStore) Recall(ctx context.Context, userID string, personaID *string, query string, limit int) ([]models.Memory, error) { - if limit <= 0 || limit > 100 { - limit = 20 - } - - // Build UNION query across applicable scopes - parts := []string{} - args := []interface{}{} - - // 1. User-scope memories - userPart := ` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories - WHERE scope = 'user' AND owner_id = ? AND status = 'active' - ` - args = append(args, userID) - - if query != "" { - userPart += " AND (key || ' ' || value) LIKE ?" - args = append(args, "%"+query+"%") - } - parts = append(parts, userPart) - - // 2. Persona-scope memories - if personaID != nil && *personaID != "" { - personaPart := ` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories - WHERE scope = 'persona' AND owner_id = ? AND status = 'active' - ` - args = append(args, *personaID) - if query != "" { - personaPart += " AND (key || ' ' || value) LIKE ?" - args = append(args, "%"+query+"%") - } - parts = append(parts, personaPart) - - // 3. Persona+user memories - puPart := ` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories - WHERE scope = 'persona_user' AND owner_id = ? AND user_id = ? AND status = 'active' - ` - args = append(args, *personaID, userID) - if query != "" { - puPart += " AND (key || ' ' || value) LIKE ?" - args = append(args, "%"+query+"%") - } - parts = append(parts, puPart) - } - - fullQuery := fmt.Sprintf(` - SELECT * FROM (%s) - ORDER BY - CASE scope - WHEN 'persona_user' THEN 0 - WHEN 'persona' THEN 1 - WHEN 'user' THEN 2 - END, - confidence DESC, - updated_at DESC - LIMIT ? - `, strings.Join(parts, " UNION ALL ")) - args = append(args, limit) - - rows, err := DB.QueryContext(ctx, fullQuery, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - return s.scanMemories(rows) -} - -func (s *MemoryStore) CountByOwner(ctx context.Context, scope, ownerID string) (int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM memories - WHERE scope = ? AND owner_id = ? AND status = 'active' - `, scope, ownerID).Scan(&count) - return count, err -} - -func (s *MemoryStore) ListByStatus(ctx context.Context, status string, limit int) ([]models.Memory, error) { - if limit <= 0 || limit > 500 { - limit = 100 - } - rows, err := DB.QueryContext(ctx, ` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at - FROM memories - WHERE status = ? - ORDER BY created_at DESC - LIMIT ? - `, status, limit) - if err != nil { - return nil, err - } - defer rows.Close() - return s.scanMemories(rows) -} - -// ── Helpers ───────────────────────────────── - -func (s *MemoryStore) scanMemories(rows *sql.Rows) ([]models.Memory, error) { - var memories []models.Memory - for rows.Next() { - var m models.Memory - if err := rows.Scan( - &m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value, - &m.SourceChannelID, &m.Confidence, &m.Status, - st(&m.CreatedAt), st(&m.UpdatedAt), - ); err != nil { - return nil, err - } - memories = append(memories, m) - } - if memories == nil { - memories = []models.Memory{} - } - return memories, rows.Err() -} - -// ensure compile-time interface satisfaction -var _ store.MemoryStore = (*MemoryStore)(nil) - -// ── CS5b additions (v0.29.0) ──────────────────────────────────────────── - -func (s *MemoryStore) SetEmbedding(ctx context.Context, id, embedding string) error { - _, err := DB.ExecContext(ctx, - `UPDATE memories SET embedding = ? WHERE id = ?`, embedding, id) - return err -} - -func (s *MemoryStore) GetLastExtractionMessageID(ctx context.Context, channelID, userID string) (string, error) { - var lastID string - err := DB.QueryRowContext(ctx, - `SELECT last_message_id FROM memory_extraction_log WHERE channel_id = ? AND user_id = ?`, - channelID, userID).Scan(&lastID) - if err != nil { - return "", nil - } - return lastID, nil -} - -func (s *MemoryStore) UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO memory_extraction_log (id, channel_id, user_id, last_message_id, memory_count) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(channel_id, user_id) DO UPDATE SET - last_message_id = excluded.last_message_id, - extracted_at = datetime('now'), - memory_count = memory_extraction_log.memory_count + excluded.memory_count - `, store.NewID(), channelID, userID, lastMessageID, count) - return err -} - -// ── v0.30.1 — Memory Compaction ──────────────────────────────────────── - -func (s *MemoryStore) DecayConfidence(ctx context.Context, olderThan string, decayFactor float64) (int, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE memories - SET confidence = confidence * ?, - updated_at = datetime('now') - WHERE status = 'active' - AND updated_at < ? - AND confidence > 0.1 - `, decayFactor, olderThan) - if err != nil { - return 0, err - } - n, _ := res.RowsAffected() - return int(n), nil -} - -func (s *MemoryStore) Prune(ctx context.Context, belowConfidence float64) (int, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE memories - SET status = 'archived', - updated_at = datetime('now') - WHERE status = 'active' - AND confidence < ? - `, belowConfidence) - if err != nil { - return 0, err - } - n, _ := res.RowsAffected() - return int(n), nil -} diff --git a/server/store/sqlite/memory_hybrid.go b/server/store/sqlite/memory_hybrid.go deleted file mode 100644 index 62f7fc0..0000000 --- a/server/store/sqlite/memory_hybrid.go +++ /dev/null @@ -1,146 +0,0 @@ -package sqlite - -import ( - "context" - "encoding/json" - "strings" - - "switchboard-core/models" -) - -// RecallHybrid returns active memories using vector similarity + keyword search. -// SQLite has no pgvector, so we load embeddings and compute cosine similarity in Go. -func (s *MemoryStore) RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error) { - if len(queryVec) == 0 { - return s.Recall(ctx, userID, personaID, query, limit) - } - if limit <= 0 || limit > 100 { - limit = 30 - } - - // Load all active memories with embeddings for applicable scopes - semantic, err := s.recallSemantic(ctx, userID, personaID, queryVec, limit) - if err != nil || len(semantic) == 0 { - return s.Recall(ctx, userID, personaID, query, limit) - } - - var keyword []models.Memory - if query != "" { - keyword, _ = s.Recall(ctx, userID, personaID, query, limit) - } - - return mergeResultsSQLite(semantic, keyword, limit), nil -} - -type scoredMemory struct { - Memory models.Memory - Similarity float64 -} - -func (s *MemoryStore) recallSemantic(ctx context.Context, userID string, personaID *string, queryVec []float64, limit int) ([]models.Memory, error) { - // Build query to load memories with embeddings - parts := []string{} - args := []interface{}{} - - userPart := ` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at, embedding - FROM memories - WHERE scope = 'user' AND owner_id = ? AND status = 'active' - AND embedding IS NOT NULL - ` - args = append(args, userID) - parts = append(parts, userPart) - - if personaID != nil && *personaID != "" { - personaPart := ` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at, embedding - FROM memories - WHERE scope = 'persona' AND owner_id = ? AND status = 'active' - AND embedding IS NOT NULL - ` - args = append(args, *personaID) - parts = append(parts, personaPart) - - puPart := ` - SELECT id, scope, owner_id, user_id, key, value, - source_channel_id, confidence, status, created_at, updated_at, embedding - FROM memories - WHERE scope = 'persona_user' AND owner_id = ? AND user_id = ? AND status = 'active' - AND embedding IS NOT NULL - ` - args = append(args, *personaID, userID) - parts = append(parts, puPart) - } - - fullQuery := strings.Join(parts, " UNION ALL ") - rows, err := DB.QueryContext(ctx, fullQuery, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - // Score each memory by cosine similarity - var scored []scoredMemory - for rows.Next() { - var m models.Memory - var embeddingJSON string - if err := rows.Scan( - &m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value, - &m.SourceChannelID, &m.Confidence, &m.Status, - st(&m.CreatedAt), st(&m.UpdatedAt), &embeddingJSON, - ); err != nil { - continue - } - - var vec []float64 - if err := json.Unmarshal([]byte(embeddingJSON), &vec); err != nil { - continue - } - - // cosineSimilarity is defined in knowledge_bases.go (same package) - sim := cosineSimilarity(queryVec, vec) - if sim > 0.3 { - scored = append(scored, scoredMemory{Memory: m, Similarity: sim}) - } - } - - // Sort by similarity descending (simple insertion sort for small N) - for i := 0; i < len(scored); i++ { - for j := i + 1; j < len(scored); j++ { - if scored[j].Similarity > scored[i].Similarity { - scored[i], scored[j] = scored[j], scored[i] - } - } - } - - // Take top N - result := make([]models.Memory, 0, limit) - for i := 0; i < len(scored) && i < limit; i++ { - result = append(result, scored[i].Memory) - } - return result, rows.Err() -} - -func mergeResultsSQLite(semantic, keyword []models.Memory, limit int) []models.Memory { - seen := make(map[string]bool, len(semantic)) - result := make([]models.Memory, 0, limit) - - for _, m := range semantic { - if len(result) >= limit { - break - } - seen[m.ID] = true - result = append(result, m) - } - for _, m := range keyword { - if len(result) >= limit { - break - } - if !seen[m.ID] { - result = append(result, m) - } - } - return result -} diff --git a/server/store/sqlite/message.go b/server/store/sqlite/message.go deleted file mode 100644 index 72e0491..0000000 --- a/server/store/sqlite/message.go +++ /dev/null @@ -1,310 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "strings" - "time" - - "switchboard-core/models" - "switchboard-core/store" -) - -type MessageStore struct{} - -func NewMessageStore() *MessageStore { return &MessageStore{} } - -func (s *MessageStore) Create(ctx context.Context, m *models.Message) error { - toolCallsJSON := ToJSON(m.ToolCalls) - metadataJSON := ToJSON(m.Metadata) - 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 id, channel_id, role, content, model, tokens_used, tool_calls, metadata, - parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at - FROM path ORDER BY created_at ASC`, messageID) - if err != nil { - return nil, err - } - defer rows.Close() - return scanMessages(rows) -} - -func (s *MessageStore) GetNextSiblingIndex(ctx context.Context, parentID string) (int, error) { - var maxIdx sql.NullInt64 - err := DB.QueryRowContext(ctx, - "SELECT MAX(sibling_index) FROM messages WHERE parent_id = ? 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() -} - -// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *MessageStore) CountAll(ctx context.Context) (int, error) { - var count int - err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&count) - return count, err -} - -// ── CS5c additions (v0.29.0) ───────────────────────────────────────────── - -func (s *MessageStore) SearchInChannel(ctx context.Context, channelID, query, roleFilter string, limit int) ([]store.ChannelSearchResult, error) { - words := strings.Fields(query) - if len(words) == 0 { - return []store.ChannelSearchResult{}, nil - } - - q := `SELECT m.id, m.role, SUBSTR(m.content, 1, 300), m.created_at - FROM messages m - WHERE m.channel_id = ? - AND m.deleted_at IS NULL - AND m.role IN ('user', 'assistant')` - args := []interface{}{channelID} - - if roleFilter == "user" || roleFilter == "assistant" { - q += " AND m.role = ?" - args = append(args, roleFilter) - } - - for _, w := range words { - q += " AND m.content LIKE ?" - args = append(args, "%"+w+"%") - } - - q += " ORDER BY m.created_at DESC LIMIT ?" - args = append(args, limit) - - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - results := make([]store.ChannelSearchResult, 0) - for rows.Next() { - var r store.ChannelSearchResult - if err := rows.Scan(&r.MessageID, &r.Role, &r.Excerpt, st(&r.Timestamp)); err != nil { - continue - } - r.Rank = 1.0 // no ranking on SQLite - results = append(results, r) - } - return results, rows.Err() -} - -// ── CS7a additions (v0.29.0) ──────────────────────────────────────────── - -func (s *MessageStore) ListWithSenderInfo(ctx context.Context, channelID string, limit, offset int) ([]store.MessageWithSender, int, error) { - var total int - err := DB.QueryRowContext(ctx, - `SELECT COUNT(*) FROM messages WHERE channel_id = ? AND deleted_at IS NULL`, - channelID).Scan(&total) - if err != nil { - return nil, 0, err - } - - rows, err := DB.QueryContext(ctx, ` - SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id, - m.sibling_index, m.participant_type, m.participant_id, - CASE WHEN m.participant_type = 'user' THEN COALESCE(NULLIF(u.display_name, ''), u.username) - WHEN m.participant_type = 'persona' THEN p.name - ELSE NULL END AS sender_name, - CASE WHEN m.participant_type = 'user' THEN u.avatar_url - WHEN m.participant_type = 'persona' THEN p.avatar - ELSE NULL END AS sender_avatar, - m.created_at - FROM messages m - LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id - LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id - WHERE m.channel_id = ? AND m.deleted_at IS NULL - ORDER BY m.created_at ASC - LIMIT ? OFFSET ? - `, channelID, limit, offset) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - results := make([]store.MessageWithSender, 0) - for rows.Next() { - var m store.MessageWithSender - if err := rows.Scan( - &m.ID, &m.ChannelID, &m.Role, &m.Content, - &m.Model, &m.TokensUsed, &m.ParentID, - &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID, - &m.SenderName, &m.SenderAvatar, - &m.CreatedAt, - ); err != nil { - return nil, total, err - } - results = append(results, m) - } - return results, total, rows.Err() -} - -func (s *MessageStore) GetParentAndRole(ctx context.Context, messageID, channelID string) (*string, string, error) { - var parentID sql.NullString - var role string - err := DB.QueryRowContext(ctx, ` - SELECT parent_id, role FROM messages - WHERE id = ? AND channel_id = ? AND deleted_at IS NULL - `, messageID, channelID).Scan(&parentID, &role) - if err != nil { - return nil, "", err - } - return NullableStringPtr(parentID), role, nil -} \ No newline at end of file diff --git a/server/store/sqlite/message_tree.go b/server/store/sqlite/message_tree.go deleted file mode 100644 index 6a35a58..0000000 --- a/server/store/sqlite/message_tree.go +++ /dev/null @@ -1,382 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "time" - - "switchboard-core/models" - "switchboard-core/store" -) - -// ── Tree Operations (v0.29.0) ─────────────────────────────────────────── - -func (s *MessageStore) GetActiveLeaf(ctx context.Context, channelID, userID string) (*string, error) { - var leafID *string - - err := DB.QueryRowContext(ctx, ` - SELECT active_leaf_id FROM channel_cursors - WHERE channel_id = ? AND user_id = ? - `, channelID, userID).Scan(&leafID) - - if err == nil && leafID != nil { - var exists bool - DB.QueryRowContext(ctx, ` - SELECT EXISTS(SELECT 1 FROM messages WHERE id = ? AND deleted_at IS NULL) - `, *leafID).Scan(&exists) - if exists { - return leafID, nil - } - } - - var fallbackID string - err = DB.QueryRowContext(ctx, ` - SELECT id FROM messages - WHERE channel_id = ? AND deleted_at IS NULL - ORDER BY created_at DESC LIMIT 1 - `, channelID).Scan(&fallbackID) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &fallbackID, nil -} - -func (s *MessageStore) GetPathToLeaf(ctx context.Context, channelID, leafID string) ([]store.PathMessage, error) { - rows, err := DB.QueryContext(ctx, ` - WITH RECURSIVE path AS ( - SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, - participant_type, participant_id, sibling_index, created_at, - 0 AS depth - FROM messages - WHERE id = ? AND channel_id = ? AND deleted_at IS NULL - - UNION ALL - - SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata, - m.participant_type, m.participant_id, m.sibling_index, m.created_at, - p.depth + 1 - FROM messages m - JOIN path p ON m.id = p.parent_id - WHERE m.deleted_at IS NULL - ) - SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, - participant_type, participant_id, sibling_index, created_at - FROM path - ORDER BY depth DESC - `, leafID, channelID) - if err != nil { - return nil, fmt.Errorf("GetPathToLeaf: %w", err) - } - defer rows.Close() - - var path []store.PathMessage - for rows.Next() { - var m store.PathMessage - var participantType, participantID sql.NullString - var toolCallsJSON, metadataJSON []byte - if err := rows.Scan( - &m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, - &toolCallsJSON, &metadataJSON, - &participantType, &participantID, &m.SiblingIndex, &m.CreatedAt, - ); err != nil { - return nil, fmt.Errorf("GetPathToLeaf scan: %w", err) - } - if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" { - raw := json.RawMessage(toolCallsJSON) - m.ToolCalls = &raw - } - if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" { - raw := json.RawMessage(metadataJSON) - m.Metadata = &raw - } - if participantType.Valid { - m.ParticipantType = participantType.String - } - if participantID.Valid { - m.ParticipantID = participantID.String - } - path = append(path, m) - } - if err := rows.Err(); err != nil { - return nil, err - } - - for i := range path { - count, _ := s.GetSiblingCount(ctx, channelID, path[i].ParentID) - path[i].SiblingCount = count - } - - _ = s.ResolveSenderInfo(ctx, path) - - return path, nil -} - -func (s *MessageStore) GetActivePath(ctx context.Context, channelID, userID string) ([]store.PathMessage, error) { - leafID, err := s.GetActiveLeaf(ctx, channelID, userID) - if err != nil { - return nil, err - } - if leafID == nil { - return []store.PathMessage{}, nil - } - return s.GetPathToLeaf(ctx, channelID, *leafID) -} - -func (s *MessageStore) GetSiblingsList(ctx context.Context, messageID string) ([]store.SiblingInfo, int, error) { - var parentID *string - var channelID string - err := DB.QueryRowContext(ctx, ` - SELECT parent_id, channel_id FROM messages - WHERE id = ? AND deleted_at IS NULL - `, messageID).Scan(&parentID, &channelID) - if err != nil { - return nil, 0, fmt.Errorf("message not found: %w", err) - } - - var rows *sql.Rows - if parentID == nil { - rows, err = DB.QueryContext(ctx, ` - SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at - FROM messages - WHERE channel_id = ? AND parent_id IS NULL AND deleted_at IS NULL - ORDER BY sibling_index, created_at - `, channelID) - } else { - rows, err = DB.QueryContext(ctx, ` - SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at - FROM messages - WHERE parent_id = ? AND deleted_at IS NULL - ORDER BY sibling_index, created_at - `, *parentID) - } - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var siblings []store.SiblingInfo - currentIdx := 0 - for i := 0; rows.Next(); i++ { - var si store.SiblingInfo - if err := rows.Scan(&si.ID, &si.Role, &si.Model, &si.SiblingIndex, &si.Preview, &si.CreatedAt); err != nil { - return nil, 0, err - } - if si.ID == messageID { - currentIdx = i - } - siblings = append(siblings, si) - } - - return siblings, currentIdx, rows.Err() -} - -func (s *MessageStore) GetSiblingCount(ctx context.Context, channelID string, parentID *string) (int, error) { - var count int - var err error - if parentID == nil { - err = DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM messages - WHERE channel_id = ? AND parent_id IS NULL AND deleted_at IS NULL - `, channelID).Scan(&count) - } else { - err = DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM messages - WHERE parent_id = ? AND deleted_at IS NULL - `, *parentID).Scan(&count) - } - if err != nil || count == 0 { - return 1, nil - } - return count, nil -} - -func (s *MessageStore) FindLeafFromMessage(ctx context.Context, messageID string) (string, error) { - var leafID string - err := DB.QueryRowContext(ctx, ` - WITH RECURSIVE descendants AS ( - SELECT id, 0 AS depth - FROM messages - WHERE id = ? AND deleted_at IS NULL - - UNION ALL - - SELECT child.id, d.depth + 1 - FROM messages child - JOIN descendants d ON child.parent_id = d.id - WHERE child.deleted_at IS NULL - AND child.sibling_index = ( - SELECT MIN(sibling_index) FROM messages - WHERE parent_id = d.id AND deleted_at IS NULL - ) - ) - SELECT id FROM descendants - ORDER BY depth DESC - LIMIT 1 - `, messageID).Scan(&leafID) - - if err != nil { - return messageID, nil - } - return leafID, nil -} - -func (s *MessageStore) NextSiblingIndexForParent(ctx context.Context, channelID string, parentID *string) (int, error) { - var maxIdx sql.NullInt64 - var err error - if parentID == nil { - err = DB.QueryRowContext(ctx, ` - SELECT MAX(sibling_index) FROM messages - WHERE channel_id = ? AND parent_id IS NULL AND deleted_at IS NULL - `, channelID).Scan(&maxIdx) - } else { - 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, nil - } - return int(maxIdx.Int64) + 1, nil -} - -func (s *MessageStore) HasPersonaMessages(ctx context.Context, channelID string) (bool, error) { - var id string - err := DB.QueryRowContext(ctx, ` - SELECT id FROM messages - WHERE channel_id = ? AND role = 'assistant' AND participant_type = 'persona' - LIMIT 1 - `, channelID).Scan(&id) - return err == nil && id != "", nil -} - -func (s *MessageStore) CreateWithCursor(ctx context.Context, m *models.Message, cursorUserID string) error { - m.ID = store.NewID() - now := time.Now().UTC() - m.CreatedAt = now - - // tool_calls: serialize for SQLite - var tcVal interface{} - if m.ToolCalls != nil { - tcVal = ToJSON(m.ToolCalls) - } - - _, err := DB.ExecContext(ctx, ` - INSERT INTO messages (id, channel_id, role, content, model, tokens_used, - tool_calls, parent_id, participant_type, participant_id, - provider_config_id, sibling_index, created_at) - VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?) - `, m.ID, m.ChannelID, m.Role, m.Content, safeModelStr(m.Model), m.TokensUsed, - tcVal, models.NullString(m.ParentID), - m.ParticipantType, m.ParticipantID, - safeStringPtr(m.ProviderConfigID), m.SiblingIndex, - now.Format(timeFmt), - ) - if err != nil { - return fmt.Errorf("CreateWithCursor insert: %w", err) - } - - // Update cursor - if cursorUserID != "" { - _, _ = 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(), m.ChannelID, cursorUserID, m.ID) - } - - // Touch channel - _, _ = DB.ExecContext(ctx, `UPDATE channels SET updated_at = datetime('now') WHERE id = ?`, m.ChannelID) - - return nil -} - -func (s *MessageStore) ResolveSenderInfo(ctx context.Context, path []store.PathMessage) error { - userIDs := map[string]bool{} - personaIDs := map[string]bool{} - for _, m := range path { - if m.ParticipantID == "" { - continue - } - switch m.ParticipantType { - case "user": - userIDs[m.ParticipantID] = true - case "persona": - personaIDs[m.ParticipantID] = true - } - } - - userNames := map[string]string{} - userAvatars := map[string]string{} - for uid := range userIDs { - var name, avatar sql.NullString - _ = DB.QueryRowContext(ctx, ` - SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = ? - `, uid).Scan(&name, &avatar) - if name.Valid { - userNames[uid] = name.String - } - if avatar.Valid { - userAvatars[uid] = avatar.String - } - } - - personaNames := map[string]string{} - personaAvatars := map[string]string{} - for pid := range personaIDs { - var name, avatar sql.NullString - _ = DB.QueryRowContext(ctx, ` - SELECT name, avatar FROM personas WHERE id = ? - `, pid).Scan(&name, &avatar) - if name.Valid { - personaNames[pid] = name.String - } - if avatar.Valid { - personaAvatars[pid] = avatar.String - } - } - - for i := range path { - pid := path[i].ParticipantID - switch path[i].ParticipantType { - case "user": - if n, ok := userNames[pid]; ok { - path[i].SenderName = &n - } - if a, ok := userAvatars[pid]; ok && a != "" { - path[i].SenderAvatar = &a - } - case "persona": - if n, ok := personaNames[pid]; ok { - path[i].SenderName = &n - } - if a, ok := personaAvatars[pid]; ok && a != "" { - path[i].SenderAvatar = &a - } - } - } - - return nil -} - -// ── helpers ───────────────────────────────── - -func safeModelStr(m string) string { - if m == "" { - return "" - } - return m -} - -func safeStringPtr(s *string) string { - if s == nil { - return "" - } - return *s -} diff --git a/server/store/sqlite/note.go b/server/store/sqlite/note.go deleted file mode 100644 index 1bbaf67..0000000 --- a/server/store/sqlite/note.go +++ /dev/null @@ -1,305 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "time" - "strings" - - "switchboard-core/models" - "switchboard-core/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, source_message_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.SourceMessageID), - 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, sourceMessageID, teamID sql.NullString - var tagsJSON, metadataJSON string - err := DB.QueryRowContext(ctx, ` - SELECT id, user_id, title, content, folder_path, tags, metadata, - source_channel_id, source_message_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, &sourceMessageID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt), - ) - if err != nil { - return nil, err - } - n.Tags = ScanArray(tagsJSON) - n.SourceChannelID = NullableStringPtr(sourceChannelID) - n.SourceMessageID = NullableStringPtr(sourceMessageID) - 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, source_message_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, source_message_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) SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error) { - if limit <= 0 || limit > 50 { - limit = 10 - } - rows, err := DB.QueryContext(ctx, ` - SELECT id, title, folder_path - FROM notes - WHERE user_id = ? AND title LIKE '%' || ? || '%' COLLATE NOCASE - ORDER BY updated_at DESC - LIMIT ?`, userID, query, limit) - if err != nil { - return nil, err - } - defer rows.Close() - - var results []models.Note - for rows.Next() { - var n models.Note - if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath); err != nil { - return nil, err - } - results = append(results, n) - } - return results, rows.Err() -} - -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, sourceMessageID, teamID sql.NullString - var tagsJSON, metadataJSON string - err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath, - &tagsJSON, &metadataJSON, - &sourceChannelID, &sourceMessageID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt)) - if err != nil { - return nil, 0, err - } - n.Tags = ScanArray(tagsJSON) - n.SourceChannelID = NullableStringPtr(sourceChannelID) - n.SourceMessageID = NullableStringPtr(sourceMessageID) - n.TeamID = NullableStringPtr(teamID) - json.Unmarshal([]byte(metadataJSON), &n.Metadata) - result = append(result, n) - } - return result, total, rows.Err() -} - -// ── CS5c additions (v0.29.0) ────────────────────────────────────────── - -// SetEmbedding is a no-op on SQLite (pgvector is PG-only). -func (s *NoteStore) SetEmbedding(ctx context.Context, noteID, vecStr string) error { - return nil -} - -// SearchKeyword performs LIKE-based keyword search on SQLite. -func (s *NoteStore) SearchKeyword(ctx context.Context, userID, query string, limit int) ([]store.NoteSearchResult, error) { - words := strings.Fields(query) - if len(words) == 0 { - return []store.NoteSearchResult{}, nil - } - - q := `SELECT id, title, folder_path, tags, SUBSTR(content, 1, 500) - FROM notes WHERE user_id = ?` - args := []interface{}{userID} - - for _, w := range words { - pattern := "%" + w + "%" - q += " AND (title LIKE ? OR content LIKE ?)" - args = append(args, pattern, pattern) - } - q += " ORDER BY updated_at DESC LIMIT ?" - args = append(args, limit) - - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - results := make([]store.NoteSearchResult, 0) - for rows.Next() { - var r store.NoteSearchResult - var tagsJSON string - if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &tagsJSON, &r.Excerpt); err != nil { - continue - } - r.Tags = ScanArray(tagsJSON) - if r.Tags == nil { - r.Tags = []string{} - } - r.Rank = 1.0 // no ranking on SQLite - results = append(results, r) - } - return results, rows.Err() -} - -// SearchSemantic returns empty results on SQLite (vector search requires PG). -func (s *NoteStore) SearchSemantic(ctx context.Context, userID, vecStr string, limit int) ([]store.NoteSearchResult, error) { - return []store.NoteSearchResult{}, nil -} - -func (s *NoteStore) ListFolders(ctx context.Context, userID string) ([]store.FolderInfo, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT DISTINCT folder_path, COUNT(*) AS count - FROM notes WHERE user_id = ? - GROUP BY folder_path - ORDER BY folder_path - `, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - results := make([]store.FolderInfo, 0) - for rows.Next() { - var f store.FolderInfo - if err := rows.Scan(&f.Path, &f.Count); err != nil { - continue - } - results = append(results, f) - } - return results, rows.Err() -} diff --git a/server/store/sqlite/note_link.go b/server/store/sqlite/note_link.go deleted file mode 100644 index 04ae5b9..0000000 --- a/server/store/sqlite/note_link.go +++ /dev/null @@ -1,182 +0,0 @@ -package sqlite - -import ( - "context" - - "switchboard-core/models" -) - -type NoteLinkStore struct{} - -func NewNoteLinkStore() *NoteLinkStore { return &NoteLinkStore{} } - -func (s *NoteLinkStore) ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error { - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - // Delete existing links for this source note - if _, err := tx.ExecContext(ctx, - "DELETE FROM note_links WHERE source_note_id = ?", sourceNoteID); err != nil { - return err - } - - // Insert new links - if len(links) > 0 { - stmt, err := tx.PrepareContext(ctx, ` - INSERT OR IGNORE INTO note_links (source_note_id, target_note_id, target_title, display_text, is_transclusion) - VALUES (?, ?, ?, ?, ?)`) - if err != nil { - return err - } - defer stmt.Close() - - for _, l := range links { - var targetID interface{} - if l.TargetNoteID != nil { - targetID = *l.TargetNoteID - } - isTransclusion := 0 - if l.IsTransclusion { - isTransclusion = 1 - } - if _, err := stmt.ExecContext(ctx, - sourceNoteID, targetID, l.TargetTitle, l.DisplayText, isTransclusion, - ); err != nil { - return err - } - } - } - - return tx.Commit() -} - -func (s *NoteLinkStore) ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error { - _, err := DB.ExecContext(ctx, ` - UPDATE note_links - SET target_note_id = ? - WHERE target_note_id IS NULL - AND LOWER(target_title) = LOWER(?) - AND source_note_id IN (SELECT id FROM notes WHERE user_id = ?)`, - targetNoteID, title, userID) - return err -} - -func (s *NoteLinkStore) Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT n.id, n.title, n.folder_path, n.updated_at, COALESCE(nl.display_text, '') - FROM note_links nl - JOIN notes n ON n.id = nl.source_note_id - WHERE nl.target_note_id = ? - ORDER BY n.updated_at DESC`, noteID) - if err != nil { - return nil, err - } - defer rows.Close() - - var results []models.NoteLinkResult - for rows.Next() { - var r models.NoteLinkResult - if err := rows.Scan(&r.SourceNoteID, &r.Title, &r.FolderPath, - st(&r.UpdatedAt), &r.DisplayText); err != nil { - return nil, err - } - results = append(results, r) - } - return results, rows.Err() -} - -func (s *NoteLinkStore) Graph(ctx context.Context, userID string) (*models.NoteGraph, error) { - graph := &models.NoteGraph{ - Nodes: make([]models.NoteGraphNode, 0), - Edges: make([]models.NoteGraphEdge, 0), - Unresolved: make([]models.NoteGraphDangling, 0), - } - - // Nodes: all user's notes with link counts - nodeRows, err := DB.QueryContext(ctx, ` - SELECT n.id, n.title, n.folder_path, n.tags, n.updated_at, - COALESCE(outbound.cnt, 0) + COALESCE(inbound.cnt, 0) AS link_count - FROM notes n - LEFT JOIN ( - SELECT source_note_id, COUNT(*) AS cnt FROM note_links - WHERE target_note_id IS NOT NULL GROUP BY source_note_id - ) outbound ON outbound.source_note_id = n.id - LEFT JOIN ( - SELECT target_note_id, COUNT(*) AS cnt FROM note_links - WHERE target_note_id IS NOT NULL GROUP BY target_note_id - ) inbound ON inbound.target_note_id = n.id - WHERE n.user_id = ? - ORDER BY n.updated_at DESC`, userID) - if err != nil { - return nil, err - } - defer nodeRows.Close() - - for nodeRows.Next() { - var node models.NoteGraphNode - var tagsJSON string - if err := nodeRows.Scan(&node.ID, &node.Title, &node.FolderPath, - &tagsJSON, &node.UpdatedAt, &node.LinkCount); err != nil { - return nil, err - } - node.Tags = ScanArray(tagsJSON) - if node.Tags == nil { - node.Tags = []string{} - } - graph.Nodes = append(graph.Nodes, node) - } - if err := nodeRows.Err(); err != nil { - return nil, err - } - - // Resolved edges - edgeRows, err := DB.QueryContext(ctx, ` - SELECT nl.source_note_id, nl.target_note_id, nl.target_title, nl.is_transclusion - FROM note_links nl - JOIN notes n ON n.id = nl.source_note_id - WHERE n.user_id = ? - AND nl.target_note_id IS NOT NULL`, userID) - if err != nil { - return nil, err - } - defer edgeRows.Close() - - for edgeRows.Next() { - var edge models.NoteGraphEdge - var isTransclusion int - if err := edgeRows.Scan(&edge.Source, &edge.Target, - &edge.Title, &isTransclusion); err != nil { - return nil, err - } - edge.IsTransclusion = isTransclusion != 0 - graph.Edges = append(graph.Edges, edge) - } - if err := edgeRows.Err(); err != nil { - return nil, err - } - - // Unresolved links (dangling) - danglingRows, err := DB.QueryContext(ctx, ` - SELECT nl.source_note_id, nl.target_title - FROM note_links nl - JOIN notes n ON n.id = nl.source_note_id - WHERE n.user_id = ? - AND nl.target_note_id IS NULL`, userID) - if err != nil { - return nil, err - } - defer danglingRows.Close() - - for danglingRows.Next() { - var d models.NoteGraphDangling - if err := danglingRows.Scan(&d.Source, &d.Title); err != nil { - return nil, err - } - graph.Unresolved = append(graph.Unresolved, d) - } - - return graph, danglingRows.Err() -} diff --git a/server/store/sqlite/persona.go b/server/store/sqlite/persona.go deleted file mode 100644 index b19aba8..0000000 --- a/server/store/sqlite/persona.go +++ /dev/null @@ -1,423 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "time" - - "switchboard-core/models" - "switchboard-core/store" -) - -type PersonaStore struct{} - -func NewPersonaStore() *PersonaStore { return &PersonaStore{} } - -const personaCols = `id, name, handle, 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, memory_enabled, memory_extraction_prompt, - 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 - // Auto-generate handle from name if not set - if p.Handle == "" { - p.Handle = models.HandleFromName(p.Name) - } - _, err := DB.ExecContext(ctx, ` - INSERT INTO personas (id, name, handle, 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, memory_enabled, memory_extraction_prompt, - created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - p.ID, p.Name, p.Handle, 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, - p.MemoryEnabled, p.MemoryExtractionPrompt, - 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.Handle != nil { - b.Set("handle", *patch.Handle) - } - 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 patch.MemoryEnabled != nil { - b.Set("memory_enabled", *patch.MemoryEnabled) - } - if patch.MemoryExtractionPrompt != nil { - b.Set("memory_extraction_prompt", *patch.MemoryExtractionPrompt) - } - 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) -} - -// ── 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, handle sql.NullString - var temp, topP sql.NullFloat64 - var maxTokens, thinkingBudget sql.NullInt64 - err := row.Scan( - &p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar, - &p.BaseModelID, &providerConfigID, - &p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP, - &p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared, - &p.MemoryEnabled, &p.MemoryExtractionPrompt, - st(&p.CreatedAt), st(&p.UpdatedAt), - ) - if err != nil { - return nil, err - } - if handle.Valid { - p.Handle = handle.String - } - 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, handle sql.NullString - var temp, topP sql.NullFloat64 - var maxTokens, thinkingBudget sql.NullInt64 - err := rows.Scan( - &p.ID, &p.Name, &handle, &p.Description, &p.Icon, &p.Avatar, - &p.BaseModelID, &providerConfigID, - &p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP, - &p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared, - &p.MemoryEnabled, &p.MemoryExtractionPrompt, - st(&p.CreatedAt), st(&p.UpdatedAt), - ) - if err != nil { - return nil, err - } - if handle.Valid { - p.Handle = handle.String - } - 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() -} diff --git a/server/store/sqlite/persona_group.go b/server/store/sqlite/persona_group.go deleted file mode 100644 index 08f4644..0000000 --- a/server/store/sqlite/persona_group.go +++ /dev/null @@ -1,144 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "time" - - "switchboard-core/models" - "switchboard-core/store" -) - -type PersonaGroupStore struct{} - -func NewPersonaGroupStore() *PersonaGroupStore { return &PersonaGroupStore{} } - -const personaGroupCols = `id, name, description, owner_id, scope, team_id, created_at, updated_at` - -func (s *PersonaGroupStore) List(ctx context.Context, ownerID string) ([]models.PersonaGroup, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT `+personaGroupCols+` FROM persona_groups - WHERE owner_id = ? ORDER BY name - `, ownerID) - if err != nil { - return nil, err - } - defer rows.Close() - - groups := []models.PersonaGroup{} - for rows.Next() { - var g models.PersonaGroup - if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, - &g.Scope, &g.TeamID, st(&g.CreatedAt), st(&g.UpdatedAt)); err != nil { - continue - } - groups = append(groups, g) - } - return groups, rows.Err() -} - -func (s *PersonaGroupStore) Get(ctx context.Context, id, ownerID string) (*models.PersonaGroup, error) { - var g models.PersonaGroup - err := DB.QueryRowContext(ctx, ` - SELECT `+personaGroupCols+` FROM persona_groups WHERE id = ? AND owner_id = ? - `, id, ownerID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, - &g.Scope, &g.TeamID, st(&g.CreatedAt), st(&g.UpdatedAt)) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &g, nil -} - -func (s *PersonaGroupStore) Create(ctx context.Context, g *models.PersonaGroup) error { - g.ID = store.NewID() - now := time.Now().UTC() - g.CreatedAt = now - g.UpdatedAt = now - _, err := DB.ExecContext(ctx, ` - INSERT INTO persona_groups (id, name, description, owner_id, scope, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, g.ID, g.Name, g.Description, g.OwnerID, g.Scope, - now.Format(timeFmt), now.Format(timeFmt)) - return err -} - -func (s *PersonaGroupStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { - for k, v := range fields { - _, err := DB.ExecContext(ctx, - `UPDATE persona_groups SET `+k+` = ?, updated_at = datetime('now') WHERE id = ?`, v, id) - if err != nil { - return err - } - } - return nil -} - -func (s *PersonaGroupStore) Delete(ctx context.Context, id, ownerID string) (int64, error) { - result, err := DB.ExecContext(ctx, - `DELETE FROM persona_groups WHERE id = ? AND owner_id = ?`, id, ownerID) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -func (s *PersonaGroupStore) GetOwnerID(ctx context.Context, id string) (string, error) { - var ownerID string - err := DB.QueryRowContext(ctx, - `SELECT owner_id FROM persona_groups WHERE id = ?`, id).Scan(&ownerID) - if err == sql.ErrNoRows { - return "", nil - } - return ownerID, err -} - -func (s *PersonaGroupStore) AddMember(ctx context.Context, groupID, personaID string, isLeader bool) error { - if isLeader { - _, _ = DB.ExecContext(ctx, - `UPDATE persona_group_members SET is_leader = 0 WHERE group_id = ?`, groupID) - } - id := store.NewID() - _, err := DB.ExecContext(ctx, ` - INSERT INTO persona_group_members (id, group_id, persona_id, is_leader) - VALUES (?, ?, ?, ?) - ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = excluded.is_leader - `, id, groupID, personaID, isLeader) - return err -} - -func (s *PersonaGroupStore) RemoveMember(ctx context.Context, memberID, groupID string) error { - _, err := DB.ExecContext(ctx, - `DELETE FROM persona_group_members WHERE id = ? AND group_id = ?`, memberID, groupID) - return err -} - -func (s *PersonaGroupStore) ListMembers(ctx context.Context, groupID string) ([]models.PersonaGroupMember, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order, - COALESCE(p.name, '') AS persona_name, - COALESCE(p.handle, '') AS persona_handle, - COALESCE(p.avatar, '') AS persona_avatar - FROM persona_group_members pgm - LEFT JOIN personas p ON p.id = pgm.persona_id - WHERE pgm.group_id = ? - ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id - `, groupID) - if err != nil { - return []models.PersonaGroupMember{}, nil - } - defer rows.Close() - - members := []models.PersonaGroupMember{} - for rows.Next() { - var m models.PersonaGroupMember - if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder, - &m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil { - continue - } - members = append(members, m) - } - return members, rows.Err() -} diff --git a/server/store/sqlite/persona_mention.go b/server/store/sqlite/persona_mention.go deleted file mode 100644 index 22fd9e0..0000000 --- a/server/store/sqlite/persona_mention.go +++ /dev/null @@ -1,80 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - - "switchboard-core/store" -) - -// ── Mention resolution + display info (v0.29.0) ──────────────────────── - -func (s *PersonaStore) FindActiveByHandle(ctx context.Context, handle string) (string, error) { - var id string - err := DB.QueryRowContext(ctx, ` - SELECT id FROM personas - WHERE LOWER(handle) = LOWER(?) AND is_active = 1 - LIMIT 1 - `, handle).Scan(&id) - if err == sql.ErrNoRows { - return "", nil - } - return id, err -} - -func (s *PersonaStore) FindActiveByHandlePrefix(ctx context.Context, prefix string) (string, int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE LOWER(?) AND is_active = 1 - `, prefix+"%").Scan(&count) - if err != nil { - return "", 0, err - } - if count != 1 { - return "", count, nil - } - var id string - err = DB.QueryRowContext(ctx, ` - SELECT id FROM personas WHERE LOWER(handle) LIKE LOWER(?) AND is_active = 1 - `, prefix+"%").Scan(&id) - return id, 1, err -} - -func (s *PersonaStore) GetNameByID(ctx context.Context, id string) (string, error) { - var name string - err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = ?`, id).Scan(&name) - if err == sql.ErrNoRows { - return "", nil - } - return name, err -} - -func (s *PersonaStore) GetNamesByIDs(ctx context.Context, ids []string) (map[string]string, error) { - result := make(map[string]string) - for _, id := range ids { - var name string - err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = ?`, id).Scan(&name) - if err == nil && name != "" { - result[id] = name - } - } - return result, nil -} - -func (s *PersonaStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) { - result := make(map[string]store.UserDisplayInfo) - for _, id := range ids { - var name, avatar sql.NullString - _ = DB.QueryRowContext(ctx, ` - SELECT name, avatar FROM personas WHERE id = ? - `, id).Scan(&name, &avatar) - if name.Valid { - info := store.UserDisplayInfo{Name: name.String} - if avatar.Valid { - info.Avatar = avatar.String - } - result[id] = info - } - } - return result, nil -} diff --git a/server/store/sqlite/policy.go b/server/store/sqlite/policy.go deleted file mode 100644 index deabda0..0000000 --- a/server/store/sqlite/policy.go +++ /dev/null @@ -1,62 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - - "switchboard-core/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() -} diff --git a/server/store/sqlite/pricing.go b/server/store/sqlite/pricing.go deleted file mode 100644 index 30e3f71..0000000 --- a/server/store/sqlite/pricing.go +++ /dev/null @@ -1,132 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - - "switchboard-core/models" - "switchboard-core/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 -} diff --git a/server/store/sqlite/project.go b/server/store/sqlite/project.go deleted file mode 100644 index b040064..0000000 --- a/server/store/sqlite/project.go +++ /dev/null @@ -1,502 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "fmt" - "time" - - "switchboard-core/models" - "switchboard-core/store" -) - -// ── ProjectStore ─────────────────────────── - -type ProjectStore struct{} - -func NewProjectStore() *ProjectStore { return &ProjectStore{} } - -// ── CRUD ──────────────────────────────────── - -func (s *ProjectStore) Create(ctx context.Context, p *models.Project) error { - p.ID = store.NewID() - now := time.Now().UTC() - p.CreatedAt = now - p.UpdatedAt = now - _, err := DB.ExecContext(ctx, ` - INSERT INTO projects (id, name, description, color, icon, scope, owner_id, team_id, - is_archived, settings, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - p.ID, p.Name, p.Description, p.Color, p.Icon, p.Scope, - p.OwnerID, models.NullString(p.TeamID), - boolToInt(p.IsArchived), ToJSON(p.Settings), - now.Format(timeFmt), now.Format(timeFmt), - ) - return err -} - -func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, error) { - var p models.Project - var teamID, workspaceID sql.NullString - var archived int - err := DB.QueryRowContext(ctx, ` - SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, - p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings, - p.created_at, p.updated_at, - (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_notes WHERE project_id = p.id) - FROM projects p - WHERE p.id = ?`, id).Scan( - &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, - &p.OwnerID, &teamID, &archived, &workspaceID, &p.Settings, - st(&p.CreatedAt), st(&p.UpdatedAt), - &p.ChannelCount, &p.KBCount, &p.NoteCount, - ) - if err != nil { - return nil, err - } - p.IsArchived = archived != 0 - p.TeamID = NullableStringPtr(teamID) - p.WorkspaceID = NullableStringPtr(workspaceID) - return &p, nil -} - -func (s *ProjectStore) Update(ctx context.Context, id string, patch models.ProjectPatch) error { - sets := []string{} - args := []interface{}{} - - add := func(col string, val interface{}) { - sets = append(sets, col+" = ?") - args = append(args, val) - } - - if patch.Name != nil { - add("name", *patch.Name) - } - if patch.Description != nil { - add("description", *patch.Description) - } - if patch.Color != nil { - add("color", *patch.Color) - } - if patch.Icon != nil { - add("icon", *patch.Icon) - } - if patch.IsArchived != nil { - add("is_archived", boolToInt(*patch.IsArchived)) - } - if patch.WorkspaceID != nil { - add("workspace_id", models.NullString(patch.WorkspaceID)) - } - if len(patch.Settings) > 0 { - // Merge: read existing settings, overlay with patch values, write back. - var existing models.JSONMap - _ = DB.QueryRowContext(ctx, "SELECT settings FROM projects WHERE id = ?", id).Scan(&existing) - if existing == nil { - existing = make(models.JSONMap) - } - for k, v := range patch.Settings { - existing[k] = v - } - add("settings", ToJSON(existing)) - } - if len(sets) == 0 { - return nil - } - - query := "UPDATE projects SET " - for i, s := range sets { - if i > 0 { - query += ", " - } - query += s - } - query += " WHERE id = ?" - args = append(args, id) - - res, err := DB.ExecContext(ctx, query, args...) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return sql.ErrNoRows - } - return nil -} - -func (s *ProjectStore) Delete(ctx context.Context, id string) error { - // Clear denormalized project_id on channels first (SQLite FK cascade - // handles junction tables, but ON DELETE SET NULL requires PRAGMA - // foreign_keys = ON which may not cascade reliably on all builds). - DB.ExecContext(ctx, `UPDATE channels SET project_id = NULL WHERE project_id = ?`, id) - - res, err := DB.ExecContext(ctx, "DELETE FROM projects WHERE id = ?", id) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return sql.ErrNoRows - } - return nil -} - -// ── Listing ───────────────────────────────── - -func (s *ProjectStore) ListForUser(ctx context.Context, userID string, teamIDs []string, includeArchived bool) ([]models.Project, error) { - q := ` - SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, - p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings, - p.created_at, p.updated_at, - (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_notes WHERE project_id = p.id) - FROM projects p - WHERE ( - (p.scope = 'personal' AND p.owner_id = ?) - OR p.scope = 'global'` - - args := []interface{}{userID} - - for _, tid := range teamIDs { - q += ` OR (p.scope = 'team' AND p.team_id = ?)` - args = append(args, tid) - } - q += `)` - - if !includeArchived { - q += ` AND p.is_archived = 0` - } - q += ` ORDER BY p.name` - - return s.queryProjects(ctx, q, args...) -} - -// ── Channel Association ───────────────────── - -func (s *ProjectStore) AddChannel(ctx context.Context, projectID, channelID string, position int) error { - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - // Remove existing (atomic move) - tx.ExecContext(ctx, `DELETE FROM project_channels WHERE channel_id = ?`, channelID) - - _, err = tx.ExecContext(ctx, ` - INSERT INTO project_channels (project_id, channel_id, position) - VALUES (?, ?, ?)`, - projectID, channelID, position) - if err != nil { - return err - } - - _, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = ? WHERE id = ?`, - projectID, channelID) - if err != nil { - return err - } - - return tx.Commit() -} - -func (s *ProjectStore) RemoveChannel(ctx context.Context, projectID, channelID string) error { - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - res, err := tx.ExecContext(ctx, ` - DELETE FROM project_channels WHERE project_id = ? AND channel_id = ?`, - projectID, channelID) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return sql.ErrNoRows - } - - _, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = NULL WHERE id = ?`, channelID) - if err != nil { - return err - } - - return tx.Commit() -} - -func (s *ProjectStore) ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT project_id, channel_id, position, COALESCE(folder, ''), added_at - FROM project_channels - WHERE project_id = ? - ORDER BY position, added_at`, projectID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.ProjectChannel - for rows.Next() { - var pc models.ProjectChannel - if err := rows.Scan(&pc.ProjectID, &pc.ChannelID, &pc.Position, &pc.Folder, &pc.AddedAt); err != nil { - return nil, err - } - result = append(result, pc) - } - return result, rows.Err() -} - -func (s *ProjectStore) ReorderChannels(ctx context.Context, projectID string, channelIDs []string) error { - tx, err := DB.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - for i, chID := range channelIDs { - _, err := tx.ExecContext(ctx, ` - UPDATE project_channels SET position = ? - WHERE project_id = ? AND channel_id = ?`, - i, projectID, chID) - if err != nil { - return err - } - } - return tx.Commit() -} - -// ── KB Association ────────────────────────── - -func (s *ProjectStore) AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO project_knowledge_bases (project_id, kb_id, auto_search) - VALUES (?, ?, ?) - ON CONFLICT (project_id, kb_id) DO UPDATE SET auto_search = excluded.auto_search`, - projectID, kbID, boolToInt(autoSearch)) - return err -} - -func (s *ProjectStore) RemoveKB(ctx context.Context, projectID, kbID string) error { - res, err := DB.ExecContext(ctx, ` - DELETE FROM project_knowledge_bases WHERE project_id = ? AND kb_id = ?`, - projectID, kbID) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return sql.ErrNoRows - } - return nil -} - -func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT pk.project_id, pk.kb_id, pk.auto_search, pk.added_at, - COALESCE(kb.name, '') AS name - FROM project_knowledge_bases pk - LEFT JOIN knowledge_bases kb ON kb.id = pk.kb_id - WHERE pk.project_id = ? - ORDER BY pk.added_at`, projectID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.ProjectKB - for rows.Next() { - var pkb models.ProjectKB - var autoSearch int - if err := rows.Scan(&pkb.ProjectID, &pkb.KBID, &autoSearch, &pkb.AddedAt, &pkb.Name); err != nil { - return nil, err - } - pkb.AutoSearch = autoSearch != 0 - result = append(result, pkb) - } - return result, rows.Err() -} - -func (s *ProjectStore) GetKBIDs(ctx context.Context, projectID string) ([]string, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT kb_id FROM project_knowledge_bases WHERE project_id = ?`, projectID) - 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() -} - -// ── Note Association ──────────────────────── - -func (s *ProjectStore) AddNote(ctx context.Context, projectID, noteID string) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO project_notes (project_id, note_id) - VALUES (?, ?) - ON CONFLICT DO NOTHING`, - projectID, noteID) - return err -} - -func (s *ProjectStore) RemoveNote(ctx context.Context, projectID, noteID string) error { - res, err := DB.ExecContext(ctx, ` - DELETE FROM project_notes WHERE project_id = ? AND note_id = ?`, - projectID, noteID) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return sql.ErrNoRows - } - return nil -} - -func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT pn.project_id, pn.note_id, pn.added_at, - COALESCE(n.title, '') AS title - FROM project_notes pn - LEFT JOIN notes n ON n.id = pn.note_id - WHERE pn.project_id = ? - ORDER BY pn.added_at`, projectID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.ProjectNote - for rows.Next() { - var pn models.ProjectNote - if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt, &pn.Title); err != nil { - return nil, err - } - result = append(result, pn) - } - return result, rows.Err() -} - -// ── Access Check ──────────────────────────── - -func (s *ProjectStore) UserCanAccess(ctx context.Context, userID, projectID string, teamIDs []string) (bool, error) { - q := ` - SELECT EXISTS( - SELECT 1 FROM projects p - WHERE p.id = ? AND ( - (p.scope = 'personal' AND p.owner_id = ?) - OR p.scope = 'global'` - - args := []interface{}{projectID, userID} - - for _, tid := range teamIDs { - q += ` OR (p.scope = 'team' AND p.team_id = ?)` - args = append(args, tid) - } - q += `))` - - var ok bool - err := DB.QueryRowContext(ctx, q, args...).Scan(&ok) - return ok, err -} - -func (s *ProjectStore) GetProjectIDForChannel(ctx context.Context, channelID string) (string, error) { - var projectID string - err := DB.QueryRowContext(ctx, ` - SELECT project_id FROM project_channels WHERE channel_id = ?`, - channelID).Scan(&projectID) - if err == sql.ErrNoRows { - return "", nil - } - return projectID, err -} - -// ── CS5c additions (v0.29.0) ────────────────────────────────────────── - -func (s *ProjectStore) AdminList(ctx context.Context, includeArchived bool) ([]store.AdminProject, error) { - archivedFilter := "AND p.is_archived = 0" - if includeArchived { - archivedFilter = "" - } - - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - SELECT p.id, p.name, p.description, p.scope, - p.owner_id, p.team_id, p.is_archived, - p.created_at, p.updated_at, - (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_notes WHERE project_id = p.id), - COALESCE(u.username, '') - FROM projects p - LEFT JOIN users u ON u.id = p.owner_id - WHERE 1=1 %s - ORDER BY p.updated_at DESC`, archivedFilter)) - if err != nil { - return nil, err - } - defer rows.Close() - - results := make([]store.AdminProject, 0) - for rows.Next() { - var p store.AdminProject - var teamID sql.NullString - var archived int - if err := rows.Scan( - &p.ID, &p.Name, &p.Description, &p.Scope, - &p.OwnerID, &teamID, &archived, - st(&p.CreatedAt), st(&p.UpdatedAt), - &p.ChannelCount, &p.KBCount, &p.NoteCount, - &p.OwnerName, - ); err != nil { - return nil, err - } - p.IsArchived = archived != 0 - p.TeamID = NullableStringPtr(teamID) - results = append(results, p) - } - return results, rows.Err() -} - -// ── Helpers ───────────────────────────────── - -func boolToInt(b bool) int { - if b { - return 1 - } - return 0 -} - -func (s *ProjectStore) queryProjects(ctx context.Context, q string, args ...interface{}) ([]models.Project, error) { - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, fmt.Errorf("queryProjects: %w", err) - } - defer rows.Close() - - var result []models.Project - for rows.Next() { - var p models.Project - var teamID, workspaceID sql.NullString - var archived int - if err := rows.Scan( - &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, - &p.OwnerID, &teamID, &archived, &workspaceID, &p.Settings, - st(&p.CreatedAt), st(&p.UpdatedAt), - &p.ChannelCount, &p.KBCount, &p.NoteCount, - ); err != nil { - return nil, err - } - p.IsArchived = archived != 0 - p.TeamID = NullableStringPtr(teamID) - p.WorkspaceID = NullableStringPtr(workspaceID) - result = append(result, p) - } - return result, rows.Err() -} diff --git a/server/store/sqlite/provider.go b/server/store/sqlite/provider.go deleted file mode 100644 index 454a893..0000000 --- a/server/store/sqlite/provider.go +++ /dev/null @@ -1,266 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "time" - - "switchboard-core/models" - "switchboard-core/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() -} - -// ── CS4 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *ProviderStore) DeletePersonalByOwner(ctx context.Context, ownerID string) (int64, error) { - result, err := DB.ExecContext(ctx, - `DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = ?`, ownerID) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -// ── CS6 additions (v0.29.0) ───────────────────────────────────────────── - -func (s *ProviderStore) ListAllForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) { - rows, err := DB.QueryContext(ctx, - fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = 'team' AND owner_id = ? ORDER BY name", providerCols), - teamID) - if err != nil { - return nil, err - } - defer rows.Close() - return scanProviders(rows) -} - -func (s *ProviderStore) DeleteByIDAndTeam(ctx context.Context, id, teamID string) (int64, error) { - res, err := DB.ExecContext(ctx, - `DELETE FROM provider_configs WHERE id = ? AND scope = 'team' AND owner_id = ?`, - id, teamID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -// ── CS7a additions (v0.29.0) ──────────────────────────────────────────── - -func (s *ProviderStore) FindFirstForUser(ctx context.Context, userID string) (string, error) { - var configID string - err := DB.QueryRowContext(ctx, ` - SELECT id FROM provider_configs - WHERE is_active = 1 AND ( - (scope = 'personal' AND owner_id = ?) - OR scope = 'global' - ) - ORDER BY scope ASC, created_at ASC - LIMIT 1 - `, userID).Scan(&configID) - return configID, err -} - -func (s *ProviderStore) LoadAccessible(ctx context.Context, configID, userID string) (*models.ProviderConfig, error) { - row := DB.QueryRowContext(ctx, fmt.Sprintf(` - SELECT %s 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 = ?))) - `, providerCols), configID, userID, userID) - return scanProvider(row) -} diff --git a/server/store/sqlite/routing_policy.go b/server/store/sqlite/routing_policy.go deleted file mode 100644 index ddaa8a1..0000000 --- a/server/store/sqlite/routing_policy.go +++ /dev/null @@ -1,116 +0,0 @@ -package sqlite - -import ( - "context" - "encoding/json" - "time" - - "switchboard-core/models" - "switchboard-core/store" -) - -// RoutingPolicyStore implements store.RoutingPolicyStore for SQLite. -type RoutingPolicyStore struct{} - -func NewRoutingPolicyStore() *RoutingPolicyStore { return &RoutingPolicyStore{} } - -func (s *RoutingPolicyStore) Create(ctx context.Context, p *models.RoutingPolicy) error { - p.ID = store.NewID() - configJSON, err := json.Marshal(p.Config) - if err != nil { - return err - } - now := time.Now().UTC() - p.CreatedAt = now - p.UpdatedAt = now - _, err = DB.ExecContext(ctx, ` - INSERT INTO routing_policies (id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, p.ID, p.Name, p.Scope, p.TeamID, p.Priority, p.Type, string(configJSON), boolToInt(p.IsActive), p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339)) - return err -} - -func (s *RoutingPolicyStore) Update(ctx context.Context, p *models.RoutingPolicy) error { - configJSON, err := json.Marshal(p.Config) - if err != nil { - return err - } - _, err = DB.ExecContext(ctx, ` - UPDATE routing_policies - SET name = ?, scope = ?, team_id = ?, priority = ?, - policy_type = ?, config = ?, is_active = ?, updated_at = datetime('now') - WHERE id = ? - `, p.Name, p.Scope, p.TeamID, p.Priority, p.Type, string(configJSON), boolToInt(p.IsActive), p.ID) - return err -} - -func (s *RoutingPolicyStore) Delete(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, `DELETE FROM routing_policies WHERE id = ?`, id) - return err -} - -func (s *RoutingPolicyStore) GetByID(ctx context.Context, id string) (*models.RoutingPolicy, error) { - var p models.RoutingPolicy - var configJSON string - var isActive int - err := DB.QueryRowContext(ctx, ` - SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at - FROM routing_policies WHERE id = ? - `, id).Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &isActive, st(&p.CreatedAt), st(&p.UpdatedAt)) - if err != nil { - return nil, err - } - p.IsActive = isActive != 0 - json.Unmarshal([]byte(configJSON), &p.Config) - return &p, nil -} - -func (s *RoutingPolicyStore) ListActive(ctx context.Context) ([]models.RoutingPolicy, error) { - return s.query(ctx, ` - SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at - FROM routing_policies - WHERE is_active = 1 - ORDER BY priority ASC, created_at ASC - `) -} - -func (s *RoutingPolicyStore) ListAll(ctx context.Context) ([]models.RoutingPolicy, error) { - return s.query(ctx, ` - SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at - FROM routing_policies - ORDER BY priority ASC, created_at ASC - `) -} - -func (s *RoutingPolicyStore) ListForTeam(ctx context.Context, teamID string) ([]models.RoutingPolicy, error) { - return s.query(ctx, ` - SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at - FROM routing_policies - WHERE is_active = 1 AND (scope = 'global' OR (scope = 'team' AND team_id = ?)) - ORDER BY priority ASC, created_at ASC - `, teamID) -} - -func (s *RoutingPolicyStore) query(ctx context.Context, q string, args ...interface{}) ([]models.RoutingPolicy, error) { - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.RoutingPolicy - for rows.Next() { - var p models.RoutingPolicy - var configJSON string - var isActive int - if err := rows.Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &isActive, st(&p.CreatedAt), st(&p.UpdatedAt)); err != nil { - return nil, err - } - p.IsActive = isActive != 0 - json.Unmarshal([]byte(configJSON), &p.Config) - result = append(result, p) - } - return result, rows.Err() -} - -var _ store.RoutingPolicyStore = (*RoutingPolicyStore)(nil) diff --git a/server/store/sqlite/stores.go b/server/store/sqlite/stores.go index 79dc9e8..7aed8fc 100644 --- a/server/store/sqlite/stores.go +++ b/server/store/sqlite/stores.go @@ -11,47 +11,25 @@ import ( 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(), - NoteLinks: NewNoteLinkStore(), GlobalConfig: NewGlobalConfigStore(), - Usage: NewUsageStore(), - Pricing: NewPricingStore(), - Files: NewFileStore(), - KnowledgeBases: NewKnowledgeBaseStore(), Groups: NewGroupStore(), ResourceGrants: NewResourceGrantStore(), - Memories: NewMemoryStore(), - Projects: NewProjectStore(), Notifications: NewNotificationStore(), NotifPrefs: NewNotificationPreferenceStore(), - Workspaces: NewWorkspaceStore(), - GitCredentials: &GitCredentialStore{}, - CapOverrides: NewCapOverrideStore(), - RoutingPolicies: NewRoutingPolicyStore(), - Sessions: NewSessionStore(), - Packages: NewPackageStore(), - Workflows: NewWorkflowStore(), - Tasks: NewTaskStore(), - Presence: NewPresenceStore(), - PersonaGroups: NewPersonaGroupStore(), - Folders: NewFolderStore(), - Health: NewHealthStore(), - ExtPermissions: NewExtensionPermissionStore(), - ExtData: NewExtDataStore(), - Tickets: NewTicketStore(), - RateLimits: NewRateLimitStore(), - Export: NewExportStore(), - Connections: NewConnectionStore(), - Dependencies: NewDependencyStore(), + Sessions: NewSessionStore(), + Presence: NewPresenceStore(), + Health: NewHealthStore(), + Connections: NewConnectionStore(), + Dependencies: NewDependencyStore(), + Packages: NewPackageStore(), + Workflows: NewWorkflowStore(), + Tasks: NewTaskStore(), + ExtPermissions: NewExtensionPermissionStore(), + ExtData: NewExtDataStore(), + Tickets: NewTicketStore(), + RateLimits: NewRateLimitStore(), } } diff --git a/server/store/sqlite/usage.go b/server/store/sqlite/usage.go deleted file mode 100644 index 16ff174..0000000 --- a/server/store/sqlite/usage.go +++ /dev/null @@ -1,242 +0,0 @@ -package sqlite - -import ( - "context" - "fmt" - "strings" - "time" - - "switchboard-core/models" - "switchboard-core/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() -} diff --git a/server/store/sqlite/user_mention.go b/server/store/sqlite/user_mention.go deleted file mode 100644 index 6df57f8..0000000 --- a/server/store/sqlite/user_mention.go +++ /dev/null @@ -1,62 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - - "switchboard-core/store" -) - -// ── Mention resolution + display info (v0.29.0) ──────────────────────── - -func (s *UserStore) FindActiveByHandle(ctx context.Context, handle, excludeUserID string) (string, error) { - var id string - err := DB.QueryRowContext(ctx, ` - SELECT id FROM users - WHERE LOWER(handle) = LOWER(?) AND id != ? AND is_active = 1 - LIMIT 1 - `, handle, excludeUserID).Scan(&id) - if err == sql.ErrNoRows { - return "", nil - } - return id, err -} - -func (s *UserStore) FindActiveByHandlePrefix(ctx context.Context, prefix, excludeUserID string) (string, int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM users - WHERE LOWER(handle) LIKE LOWER(?) AND id != ? AND is_active = 1 - `, prefix+"%", excludeUserID).Scan(&count) - if err != nil { - return "", 0, err - } - if count != 1 { - return "", count, nil - } - var id string - err = DB.QueryRowContext(ctx, ` - SELECT id FROM users - WHERE LOWER(handle) LIKE LOWER(?) AND id != ? AND is_active = 1 - LIMIT 1 - `, prefix+"%", excludeUserID).Scan(&id) - return id, 1, err -} - -func (s *UserStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) { - result := make(map[string]store.UserDisplayInfo) - for _, id := range ids { - var name, avatar sql.NullString - _ = DB.QueryRowContext(ctx, ` - SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = ? - `, id).Scan(&name, &avatar) - if name.Valid { - info := store.UserDisplayInfo{Name: name.String} - if avatar.Valid { - info.Avatar = avatar.String - } - result[id] = info - } - } - return result, nil -} diff --git a/server/store/sqlite/user_settings.go b/server/store/sqlite/user_settings.go deleted file mode 100644 index 0278899..0000000 --- a/server/store/sqlite/user_settings.go +++ /dev/null @@ -1,150 +0,0 @@ -package sqlite - -import ( - "context" - "fmt" - - "switchboard-core/models" - "switchboard-core/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, provider_config_id, COALESCE(hidden, 0), preferred_temperature, preferred_max_tokens, - COALESCE(sort_order, 0), 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.ProviderConfigID, &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 provider_config_id: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, COALESCE(provider_config_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, provCfgID string - if err := rows.Scan(&modelID, &provCfgID); err != nil { - return nil, err - } - result[models.CompositeModelKey(provCfgID, modelID)] = true - } - return result, rows.Err() -} - -// Set upserts a single user model setting. -func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, providerConfigID *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 - _, err := DB.ExecContext(ctx, ` - INSERT INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden, preferred_temperature, preferred_max_tokens, sort_order) - VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?, COALESCE(?, 0)) - ON CONFLICT (user_id, model_id, provider_config_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, providerConfigID, - patchBoolOrNil(patch.Hidden), - patchFloat64OrNil(patch.PreferredTemperature), - patchIntOrNil(patch.PreferredMaxTokens), - patchIntOrNil(patch.SortOrder), - ) - return err -} - -// BulkSetHidden sets the hidden state for multiple model+provider pairs at once. -func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, entries []models.HiddenEntry, hidden bool) error { - if len(entries) == 0 { - return nil - } - - for _, entry := range entries { - _, err := DB.ExecContext(ctx, ` - INSERT INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (user_id, model_id, provider_config_id) DO UPDATE SET hidden = excluded.hidden`, - store.NewID(), userID, entry.ModelID, entry.ProviderConfigID, hidden) - if err != nil { - return fmt.Errorf("set hidden for %s:%s: %w", entry.ProviderConfigID, entry.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 -} - diff --git a/server/store/sqlite/workspace.go b/server/store/sqlite/workspace.go deleted file mode 100644 index b6e6dec..0000000 --- a/server/store/sqlite/workspace.go +++ /dev/null @@ -1,452 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "math" - "sort" - "strings" - "time" - - "switchboard-core/models" - "switchboard-core/store" -) - -type WorkspaceStore struct{} - -func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} } - -// ── columns ──────────────────────────────── - -const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, indexing_enabled, git_remote_url, git_branch, git_credential_id, git_last_sync, created_at, updated_at` -const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, index_status, chunk_count, created_at, updated_at` - -// ── scanners ─────────────────────────────── - -func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) { - var w models.Workspace - var maxBytes sql.NullInt64 - var gitRemote, gitBranch, gitCredID sql.NullString - err := row.Scan( - &w.ID, &w.OwnerType, &w.OwnerID, &w.Name, - &w.RootPath, &maxBytes, &w.Status, &w.IndexingEnabled, - &gitRemote, &gitBranch, &gitCredID, stN(&w.GitLastSync), - st(&w.CreatedAt), st(&w.UpdatedAt), - ) - if err != nil { - return nil, err - } - if maxBytes.Valid { - w.MaxBytes = &maxBytes.Int64 - } - if gitRemote.Valid { - w.GitRemoteURL = &gitRemote.String - } - if gitBranch.Valid { - w.GitBranch = &gitBranch.String - } - if gitCredID.Valid { - w.GitCredentialID = &gitCredID.String - } - return &w, nil -} - -func scanWorkspaceFile(row interface{ Scan(dest ...interface{}) error }) (*models.WorkspaceFile, error) { - var f models.WorkspaceFile - var contentType sql.NullString - var sha256 sql.NullString - var indexStatus sql.NullString - err := row.Scan( - &f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory, - &contentType, &f.SizeBytes, &sha256, - &indexStatus, &f.ChunkCount, - st(&f.CreatedAt), st(&f.UpdatedAt), - ) - if err != nil { - return nil, err - } - if contentType.Valid { - f.ContentType = contentType.String - } - if sha256.Valid { - f.SHA256 = sha256.String - } - if indexStatus.Valid { - f.IndexStatus = indexStatus.String - } - return &f, nil -} - -// ── Workspace CRUD ───────────────────────── - -func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error { - if w.ID == "" { - w.ID = store.NewID() - } - now := time.Now().UTC() - w.CreatedAt = now - w.UpdatedAt = now - _, err := DB.ExecContext(ctx, ` - INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath, - w.MaxBytes, w.Status, - now.Format(timeFmt), now.Format(timeFmt), - ) - return err -} - -func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) { - row := DB.QueryRowContext(ctx, - `SELECT `+workspaceCols+` FROM workspaces WHERE id = ?`, id) - return scanWorkspace(row) -} - -func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error { - b := NewUpdate("workspaces") - if patch.Name != nil { - b.Set("name", *patch.Name) - } - if patch.MaxBytes != nil { - b.Set("max_bytes", *patch.MaxBytes) - } - if patch.Status != nil { - b.Set("status", *patch.Status) - } - if patch.IndexingEnabled != nil { - b.Set("indexing_enabled", *patch.IndexingEnabled) - } - if patch.GitRemoteURL != nil { - b.Set("git_remote_url", *patch.GitRemoteURL) - } - if patch.GitBranch != nil { - b.Set("git_branch", *patch.GitBranch) - } - if patch.GitCredentialID != nil { - b.Set("git_credential_id", *patch.GitCredentialID) - } - if !b.HasSets() { - return nil - } - b.SetExpr("updated_at", nowExpr) - b.Where("id", id) - _, err := b.Exec(DB) - return err -} - -func (s *WorkspaceStore) Delete(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id) - return err -} - -// ── Ownership lookup ─────────────────────── - -func (s *WorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) { - row := DB.QueryRowContext(ctx, - `SELECT `+workspaceCols+` FROM workspaces - WHERE owner_type = ? AND owner_id = ? AND status != 'deleting' - ORDER BY created_at DESC LIMIT 1`, - ownerType, ownerID) - return scanWorkspace(row) -} - -func (s *WorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) { - rows, err := DB.QueryContext(ctx, - `SELECT `+workspaceCols+` FROM workspaces - WHERE owner_type = ? AND owner_id = ? AND status != 'deleting' - ORDER BY created_at DESC`, - ownerType, ownerID) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []models.Workspace - for rows.Next() { - w, err := scanWorkspace(rows) - if err != nil { - return nil, err - } - out = append(out, *w) - } - return out, rows.Err() -} - -// ── File index ───────────────────────────── - -func (s *WorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error { - if f.ID == "" { - f.ID = store.NewID() - } - now := time.Now().UTC() - f.CreatedAt = now - f.UpdatedAt = now - - _, err := DB.ExecContext(ctx, ` - INSERT INTO workspace_files (id, workspace_id, path, is_directory, content_type, size_bytes, sha256, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (workspace_id, path) DO UPDATE SET - is_directory = excluded.is_directory, - content_type = excluded.content_type, - size_bytes = excluded.size_bytes, - sha256 = excluded.sha256, - updated_at = datetime('now')`, - f.ID, f.WorkspaceID, f.Path, f.IsDirectory, - models.NullString(&f.ContentType), f.SizeBytes, - models.NullString(&f.SHA256), - now.Format(timeFmt), now.Format(timeFmt), - ) - return err -} - -func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error { - _, err := DB.ExecContext(ctx, - `DELETE FROM workspace_files WHERE workspace_id = ? AND path = ?`, - workspaceID, path) - return err -} - -func (s *WorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error { - _, err := DB.ExecContext(ctx, - `DELETE FROM workspace_files WHERE workspace_id = ? AND path LIKE ?`, - workspaceID, prefix+"%") - return err -} - -func (s *WorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) { - row := DB.QueryRowContext(ctx, - `SELECT `+workspaceFileCols+` FROM workspace_files - WHERE workspace_id = ? AND path = ?`, - workspaceID, path) - return scanWorkspaceFile(row) -} - -func (s *WorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) { - var query string - var args []interface{} - - if recursive { - if prefix == "" { - query = `SELECT ` + workspaceFileCols + ` FROM workspace_files - WHERE workspace_id = ? ORDER BY path` - args = []interface{}{workspaceID} - } else { - query = `SELECT ` + workspaceFileCols + ` FROM workspace_files - WHERE workspace_id = ? AND path LIKE ? ORDER BY path` - args = []interface{}{workspaceID, prefix + "%"} - } - } else { - if prefix == "" { - query = `SELECT ` + workspaceFileCols + ` FROM workspace_files - WHERE workspace_id = ? AND path NOT LIKE '%/%' ORDER BY path` - args = []interface{}{workspaceID} - } else { - // Direct children: starts with prefix, no slash after prefix - query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files - WHERE workspace_id = ? AND path LIKE ? - AND substr(path, %d) NOT LIKE '%%/%%' - ORDER BY path`, len(prefix)+1) - args = []interface{}{workspaceID, prefix + "%"} - } - } - - rows, err := DB.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []models.WorkspaceFile - for rows.Next() { - f, err := scanWorkspaceFile(rows) - if err != nil { - return nil, err - } - out = append(out, *f) - } - return out, rows.Err() -} - -func (s *WorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error { - _, err := DB.ExecContext(ctx, - `DELETE FROM workspace_files WHERE workspace_id = ?`, workspaceID) - return err -} - -// ── Stats ────────────────────────────────── - -func (s *WorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) { - stats := &models.WorkspaceStats{WorkspaceID: workspaceID} - err := DB.QueryRowContext(ctx, ` - SELECT - COALESCE(SUM(CASE WHEN NOT is_directory THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN is_directory THEN 1 ELSE 0 END), 0), - COALESCE(SUM(size_bytes), 0) - FROM workspace_files WHERE workspace_id = ?`, - workspaceID, - ).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes) - if err != nil { - return nil, err - } - - var maxBytes sql.NullInt64 - err = DB.QueryRowContext(ctx, - `SELECT max_bytes FROM workspaces WHERE id = ?`, workspaceID, - ).Scan(&maxBytes) - if err != nil && err != sql.ErrNoRows { - return nil, err - } - if maxBytes.Valid { - stats.MaxBytes = &maxBytes.Int64 - } - - return stats, nil -} - -// ── Chunks (v0.21.2) ───────────────────────── - -func (s *WorkspaceStore) InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error { - if len(chunks) == 0 { - return nil - } - - 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.WorkspaceID, c.FileID, c.ChunkIndex, - c.Content, c.TokenCount, embJSON, ToJSON(c.Metadata)) - } - - q := fmt.Sprintf(`INSERT INTO workspace_chunks - (id, workspace_id, file_id, chunk_index, content, token_count, embedding, metadata) - VALUES %s`, strings.Join(valueParts, ", ")) - - _, err := DB.ExecContext(ctx, q, args...) - return err -} - -func (s *WorkspaceStore) DeleteChunksByFile(ctx context.Context, fileID string) error { - _, err := DB.ExecContext(ctx, `DELETE FROM workspace_chunks WHERE file_id = ?`, fileID) - return err -} - -// SimilaritySearch performs app-level cosine similarity for SQLite. -// Loads all chunks for the workspace, computes cosine distance in Go, -// and returns the top results above threshold. -func (s *WorkspaceStore) SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error) { - if len(queryVec) == 0 { - return nil, nil - } - if limit <= 0 { - limit = 10 - } - - rows, err := DB.QueryContext(ctx, ` - SELECT f.path, c.content, c.embedding, c.metadata - FROM workspace_chunks c - JOIN workspace_files f ON c.file_id = f.id - WHERE c.workspace_id = ? - AND c.embedding IS NOT NULL`, - workspaceID) - if err != nil { - return nil, err - } - defer rows.Close() - - type candidate struct { - result models.WorkspaceChunkResult - similarity float64 - } - var candidates []candidate - - for rows.Next() { - var filePath, content, embJSON string - var metaJSON sql.NullString - if err := rows.Scan(&filePath, &content, &embJSON, &metaJSON); err != nil { - return nil, err - } - - var embedding []float64 - if err := json.Unmarshal([]byte(embJSON), &embedding); err != nil { - continue // skip malformed - } - - sim := wsCosineSimilarity(queryVec, embedding) - if sim > threshold { - r := models.WorkspaceChunkResult{ - FilePath: filePath, - Content: content, - Score: sim, - } - if metaJSON.Valid { - ScanJSON(metaJSON.String, &r.Metadata) - } - if r.Metadata != nil { - if lh, ok := r.Metadata["line_start"]; ok { - if n, ok := lh.(float64); ok { - r.LineHint = int(n) - } - } - } - candidates = append(candidates, candidate{result: r, similarity: sim}) - } - } - if err := rows.Err(); err != nil { - return nil, err - } - - sort.Slice(candidates, func(i, j int) bool { - return candidates[i].similarity > candidates[j].similarity - }) - - if len(candidates) > limit { - candidates = candidates[:limit] - } - - results := make([]models.WorkspaceChunkResult, len(candidates)) - for i, c := range candidates { - results[i] = c.result - } - return results, nil -} - -func (s *WorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error { - _, err := DB.ExecContext(ctx, - `UPDATE workspace_files SET index_status = ?, chunk_count = ?, updated_at = datetime('now') WHERE id = ?`, - status, chunkCount, fileID) - return err -} - -func (s *WorkspaceStore) SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error { - _, err := DB.ExecContext(ctx, - `UPDATE workspaces SET git_last_sync = ?, updated_at = datetime('now') WHERE id = ?`, - t.Format(timeFmt), workspaceID) - return err -} - -// wsCosineSimilarity computes cosine similarity between two vectors. -// Named to avoid collision with the KB package-level function. -func wsCosineSimilarity(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)) -} \ No newline at end of file diff --git a/server/store/store_memory.go b/server/store/store_memory.go deleted file mode 100644 index 28deb26..0000000 --- a/server/store/store_memory.go +++ /dev/null @@ -1,78 +0,0 @@ -// ── store_memory.go ───────────────────────── -// MemoryStore interface for v0.18.0. -// Add this interface to store/interfaces.go and add -// Memories MemoryStore -// to the Stores struct. - -package store - -import ( - "context" - - "switchboard-core/models" -) - -// ========================================= -// MEMORY STORE (v0.18.0) -// ========================================= - -type MemoryStore interface { - // Upsert creates or updates a memory (matched on scope+owner+user+key). - // If a memory with the same key exists, value/confidence/status are updated. - Upsert(ctx context.Context, m *models.Memory) error - - // Update modifies an existing memory by ID. Use for handler edits where - // the key may change (Upsert conflicts on composite key, not on ID). - Update(ctx context.Context, m *models.Memory) error - - // GetByID returns a single memory by ID. - GetByID(ctx context.Context, id string) (*models.Memory, error) - - // List returns memories matching the filter criteria. - List(ctx context.Context, f models.MemoryFilter) ([]models.Memory, error) - - // Delete hard-deletes a memory by ID. - Delete(ctx context.Context, id string) error - - // Archive sets status to 'archived' (soft delete). - Archive(ctx context.Context, id string) error - - // Recall returns active memories relevant to a query, merging scopes. - // Combines user + persona + persona_user memories for the given context. - // Results are ordered by confidence DESC, updated_at DESC. - Recall(ctx context.Context, userID string, personaID *string, query string, limit int) ([]models.Memory, error) - - // CountByOwner returns the number of active memories for a scope+owner. - CountByOwner(ctx context.Context, scope, ownerID string) (int, error) - - // ListByStatus returns all memories with the given status across all owners. - // Used by admin review endpoints. Results ordered by created_at DESC. - ListByStatus(ctx context.Context, status string, limit int) ([]models.Memory, error) - - // RecallHybrid returns active memories using vector similarity + keyword search. - // If queryVec is nil/empty, falls back to keyword-only (same as Recall). - RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error) - - // ── CS5b additions (v0.29.0) ── - - // SetEmbedding updates the embedding vector for a memory. Handles dialect differences. - SetEmbedding(ctx context.Context, id, embedding string) error - - // GetLastExtractionMessageID returns the last processed message ID from the extraction log. - // Returns "" if no log entry exists. - GetLastExtractionMessageID(ctx context.Context, channelID, userID string) (string, error) - - // UpsertExtractionLog creates or updates the memory extraction log entry. - UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error - - // ── v0.30.1 — Memory Compaction ── - - // DecayConfidence reduces confidence for active memories not updated since olderThan. - // Formula: new_confidence = confidence * decayFactor. Skips memories with confidence <= 0.1. - // Returns the number of affected rows. - DecayConfidence(ctx context.Context, olderThan string, decayFactor float64) (int, error) - - // Prune archives active memories whose confidence has dropped below the threshold. - // Returns the number of archived rows. - Prune(ctx context.Context, belowConfidence float64) (int, error) -} diff --git a/server/store/tree_types.go b/server/store/tree_types.go deleted file mode 100644 index 92aa9aa..0000000 --- a/server/store/tree_types.go +++ /dev/null @@ -1,54 +0,0 @@ -package store - -import "encoding/json" - -// ── Tree Types (v0.29.0) ──────────────────── -// Moved from treepath package to store so MessageStore methods can -// return enriched path/sibling data without import cycles. -// treepath aliases these types for backward compatibility. - -// PathMessage represents a single message in an active path, enriched -// with sibling metadata for the frontend branch indicator. -type PathMessage struct { - ID string `json:"id"` - ParentID *string `json:"parent_id"` - Role string `json:"role"` - Content string `json:"content"` - Model *string `json:"model"` - TokensUsed *int `json:"tokens_used,omitempty"` - ToolCalls *json.RawMessage `json:"tool_calls,omitempty"` - Metadata *json.RawMessage `json:"metadata,omitempty"` - SiblingCount int `json:"sibling_count"` - SiblingIndex int `json:"sibling_index"` - ParticipantType string `json:"participant_type,omitempty"` - ParticipantID string `json:"participant_id,omitempty"` - SenderName *string `json:"sender_name,omitempty"` - SenderAvatar *string `json:"sender_avatar,omitempty"` - CreatedAt string `json:"created_at"` -} - -// SiblingInfo is a lightweight entry for the siblings listing endpoint. -type SiblingInfo struct { - ID string `json:"id"` - Role string `json:"role"` - Model *string `json:"model,omitempty"` - SiblingIndex int `json:"sibling_index"` - Preview string `json:"preview"` // first ~80 chars of content - CreatedAt string `json:"created_at"` -} - -// MentionResult is the outcome of resolving an @mention token. -// Exactly one of PersonaID, UserID, or ModelID will be non-empty. -type MentionResult struct { - ModelID string // resolved model ID (for @model mentions) - ProviderConfigID string // provider config for model-based mentions - PersonaID string // matched persona ID - PersonaName string // persona display name (for logging) - UserID string // matched user ID (for @user mentions) -} - -// UserDisplayInfo holds name + avatar for sender resolution in paths. -type UserDisplayInfo struct { - Name string - Avatar string -}