Changeset 0.29.0 (#195)
This commit is contained in:
36
server/store/extension_perm_iface.go
Normal file
36
server/store/extension_perm_iface.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ExtensionPermissionStore manages declared and granted permissions for packages.
|
||||
type ExtensionPermissionStore interface {
|
||||
// DeclareForPackage upserts the declared permissions from a manifest.
|
||||
// Any permissions in the DB for this package that are NOT in the
|
||||
// provided list are deleted (manifest is the source of truth).
|
||||
// Existing grants are preserved on upsert.
|
||||
DeclareForPackage(ctx context.Context, packageID string, permissions []string) error
|
||||
|
||||
// ListForPackage returns all declared permissions for a package.
|
||||
ListForPackage(ctx context.Context, packageID string) ([]models.ExtensionPermission, error)
|
||||
|
||||
// GrantedForPackage returns only granted permissions for a package.
|
||||
// Used at runtime to determine which modules to inject.
|
||||
GrantedForPackage(ctx context.Context, packageID string) ([]string, error)
|
||||
|
||||
// Grant marks a permission as granted by an admin.
|
||||
Grant(ctx context.Context, packageID, permission, grantedBy string) error
|
||||
|
||||
// Revoke removes a grant (sets granted=false).
|
||||
Revoke(ctx context.Context, packageID, permission string) error
|
||||
|
||||
// GrantAll grants all declared permissions for a package.
|
||||
GrantAll(ctx context.Context, packageID, grantedBy string) error
|
||||
|
||||
// DeleteForPackage removes all permission rows for a package.
|
||||
// Called when a package is uninstalled.
|
||||
DeleteForPackage(ctx context.Context, packageID string) error
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
@@ -55,6 +56,11 @@ type Stores struct {
|
||||
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
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -75,6 +81,31 @@ type ProviderStore interface {
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -103,6 +134,41 @@ type CatalogStore interface {
|
||||
// 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.
|
||||
@@ -141,6 +207,25 @@ type PersonaStore interface {
|
||||
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)
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -190,6 +275,61 @@ type UserStore interface {
|
||||
RevokeRefreshToken(ctx context.Context, tokenHash string) error
|
||||
RevokeAllRefreshTokens(ctx context.Context, userID string) error
|
||||
CleanExpiredTokens(ctx context.Context) error
|
||||
|
||||
// ── Mention resolution (v0.29.0 — moved from handler raw SQL) ──
|
||||
|
||||
// FindActiveByHandle returns a user ID by exact handle match (case-insensitive).
|
||||
// excludeUserID prevents self-mention resolution.
|
||||
FindActiveByHandle(ctx context.Context, handle, excludeUserID string) (string, error)
|
||||
|
||||
// FindActiveByHandlePrefix returns a user ID by unambiguous handle prefix.
|
||||
// Returns ("", count, nil) if ambiguous or no match.
|
||||
FindActiveByHandlePrefix(ctx context.Context, prefix, excludeUserID string) (string, int, error)
|
||||
|
||||
// GetDisplayInfoByIDs returns name + avatar for batch sender resolution.
|
||||
GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]UserDisplayInfo, error)
|
||||
|
||||
// ── CS1 additions (v0.29.0) ──
|
||||
|
||||
// Exists returns true if a user with the given ID exists and is active.
|
||||
Exists(ctx context.Context, userID string) (bool, error)
|
||||
|
||||
// SearchActive returns users matching a query (username, display_name, handle).
|
||||
// Excludes the calling user. Max 20 results.
|
||||
SearchActive(ctx context.Context, excludeUserID, query string) ([]UserSearchResult, error)
|
||||
|
||||
// ── CS2 additions (v0.29.0) ──
|
||||
|
||||
// CountByRole returns the number of users with a given role.
|
||||
CountByRole(ctx context.Context, role string) (int, error)
|
||||
|
||||
// CountAll returns the total number of users.
|
||||
CountAll(ctx context.Context) (int, error)
|
||||
|
||||
// MergeSettings performs a dialect-aware JSON merge into the user's settings column.
|
||||
MergeSettings(ctx context.Context, userID string, patch []byte) error
|
||||
|
||||
// GetVaultKeys returns the user's vault encryption state.
|
||||
GetVaultKeys(ctx context.Context, userID string) (vaultSet bool, encUEK, salt, nonce []byte, err error)
|
||||
|
||||
// UpdateVaultKeys re-wraps the user's vault keys (password change).
|
||||
UpdateVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error
|
||||
|
||||
// ── CS4 additions (v0.29.0) ──
|
||||
|
||||
// ClearVaultKeys nulls out vault columns and sets vault_set = false.
|
||||
ClearVaultKeys(ctx context.Context, userID string) error
|
||||
|
||||
// InitVaultKeys stores vault keys and sets vault_set = true (first-time init).
|
||||
InitVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error
|
||||
}
|
||||
|
||||
// UserSearchResult is a lightweight result for user search.
|
||||
type UserSearchResult struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Handle string `json:"handle"`
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -213,6 +353,37 @@ type TeamStore interface {
|
||||
GetUserTeamIDs(ctx context.Context, userID string) ([]string, error)
|
||||
IsTeamAdmin(ctx context.Context, teamID, userID string) (bool, error)
|
||||
IsMember(ctx context.Context, teamID, userID string) (bool, error)
|
||||
|
||||
// ── CS1 additions (v0.29.0) ──
|
||||
|
||||
// Exists returns true if a team with the given ID exists.
|
||||
Exists(ctx context.Context, teamID string) (bool, error)
|
||||
|
||||
// UpdateMemberRoleByID updates a member's role using the member row ID.
|
||||
UpdateMemberRoleByID(ctx context.Context, memberID, teamID, role string) (int64, error)
|
||||
|
||||
// DeleteMemberByID deletes a member using the member row ID.
|
||||
DeleteMemberByID(ctx context.Context, memberID, teamID string) (int64, error)
|
||||
|
||||
// ListTeamAuditActions returns distinct audit actions for a team's members.
|
||||
ListTeamAuditActions(ctx context.Context, teamID string) ([]string, error)
|
||||
|
||||
// ── CS5b additions (v0.29.0) ──
|
||||
|
||||
// GetFirstTeamIDForUser returns the first team_id the user belongs to, or "" if none.
|
||||
GetFirstTeamIDForUser(ctx context.Context, userID string) (string, error)
|
||||
|
||||
// ── CS6 additions (v0.29.0) ──
|
||||
|
||||
// AddMemberReturningID inserts a team member and returns the row ID.
|
||||
AddMemberReturningID(ctx context.Context, teamID, userID, role string) (string, error)
|
||||
|
||||
// HasPrivateProviderRequirement checks if a user belongs to any active team
|
||||
// with the require_private_providers setting enabled.
|
||||
HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error)
|
||||
|
||||
// MergeSettings merges a JSON string into the team's settings column.
|
||||
MergeSettings(ctx context.Context, teamID, settingsJSON string) error
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -260,6 +431,169 @@ type ChannelStore interface {
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -291,6 +625,89 @@ type MessageStore interface {
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -311,6 +728,7 @@ type AuditListOptions struct {
|
||||
ResourceID string
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
TeamID string // CS6: scope to team members
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -326,6 +744,22 @@ type NoteStore interface {
|
||||
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 {
|
||||
@@ -335,6 +769,23 @@ type NoteListOptions struct {
|
||||
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
|
||||
// =========================================
|
||||
@@ -362,6 +813,24 @@ type GlobalConfigStore interface {
|
||||
Get(ctx context.Context, key string) (models.JSONMap, error)
|
||||
Set(ctx context.Context, key string, value models.JSONMap, updatedBy string) error
|
||||
GetAll(ctx context.Context) (map[string]models.JSONMap, error)
|
||||
|
||||
// ── OIDC state (v0.29.0-cs4) ──
|
||||
|
||||
// SaveOIDCState stores a state+nonce pair for OIDC callback verification.
|
||||
SaveOIDCState(ctx context.Context, state, nonce, redirectTo string) error
|
||||
|
||||
// ConsumeOIDCState retrieves and deletes an OIDC state (one-time use).
|
||||
// Returns (nonce, redirectTo, error). Returns sql.ErrNoRows if not found.
|
||||
ConsumeOIDCState(ctx context.Context, state string) (nonce, redirectTo string, err error)
|
||||
|
||||
// CleanupOIDCState removes stale OIDC states older than 10 minutes.
|
||||
CleanupOIDCState(ctx context.Context) error
|
||||
|
||||
// ── CS6 additions (v0.29.0) ──
|
||||
|
||||
// GetString returns the raw value column as a string for simple settings
|
||||
// (e.g. bare JSON booleans like "true"/"false").
|
||||
GetString(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -419,6 +888,11 @@ type FileStore interface {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -652,6 +1126,70 @@ 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) (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)
|
||||
// =========================================
|
||||
|
||||
// HealthStore manages provider health windows. The full interface is used
|
||||
// by the health accumulator and status querier; the scheduler only needs Prune.
|
||||
type HealthStore interface {
|
||||
// Prune deletes health windows older than the given time. Returns rows affected.
|
||||
Prune(ctx context.Context, before time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// PRESENCE STORE (v0.29.0)
|
||||
// =========================================
|
||||
|
||||
// PresenceStore manages user online/offline status.
|
||||
type PresenceStore interface {
|
||||
// Heartbeat upserts the user's last_seen timestamp to now.
|
||||
Heartbeat(ctx context.Context, userID string) error
|
||||
|
||||
// GetLastSeen returns the user's last heartbeat time, or nil if never seen.
|
||||
GetLastSeen(ctx context.Context, userID string) (*time.Time, error)
|
||||
|
||||
// GetStatuses returns online/offline status for a list of user IDs.
|
||||
// Users with last_seen after threshold are "online", otherwise "offline".
|
||||
GetStatuses(ctx context.Context, userIDs []string, threshold time.Time) (map[string]string, error)
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// SHARED TYPES
|
||||
// =========================================
|
||||
|
||||
@@ -25,6 +25,10 @@ type PackageStore interface {
|
||||
// SetEnabled toggles a package's enabled state.
|
||||
SetEnabled(ctx context.Context, id string, enabled bool) error
|
||||
|
||||
// SetStatus transitions a package's lifecycle status.
|
||||
// Valid statuses: active, pending_review, suspended.
|
||||
SetStatus(ctx context.Context, id string, status string) error
|
||||
|
||||
// Delete removes a non-core package. Core packages cannot be deleted.
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
@@ -79,6 +83,7 @@ type PackageRegistration struct {
|
||||
InstalledBy *string `json:"installed_by,omitempty" db:"installed_by"`
|
||||
Manifest map[string]any `json:"manifest" db:"manifest"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Source string `json:"source" db:"source"`
|
||||
InstalledAt string `json:"installed_at" db:"installed_at"`
|
||||
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
||||
|
||||
@@ -30,6 +30,9 @@ func (s *AuditStore) List(ctx context.Context, opts store.AuditListOptions) ([]m
|
||||
al.ip_address, al.user_agent, al.created_at`, "audit_log al")
|
||||
b.Join("LEFT JOIN users u ON al.actor_id = u.id")
|
||||
|
||||
if opts.TeamID != "" {
|
||||
b.Where("al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = ?)", opts.TeamID)
|
||||
}
|
||||
if opts.ActorID != "" {
|
||||
b.Where("al.actor_id = ?", opts.ActorID)
|
||||
}
|
||||
|
||||
@@ -281,3 +281,58 @@ func scanCatalogEntries(rows *sql.Rows) ([]models.CatalogEntry, error) {
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
57
server/store/postgres/catalog_mention.go
Normal file
57
server/store/postgres/catalog_mention.go
Normal file
@@ -0,0 +1,57 @@
|
||||
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
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
@@ -471,3 +474,492 @@ func (s *ChannelStore) Purge(ctx context.Context, id string) error {
|
||||
_, 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) 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 {
|
||||
_, 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
|
||||
WHERE id = $6
|
||||
`, workflowID, version, stageData, status, time.Now().UTC(), 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
|
||||
FROM channels WHERE id = $1 AND type = 'workflow'
|
||||
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
|
||||
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 {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET current_stage = $1, stage_data = $2, last_activity_at = $3
|
||||
WHERE id = $4
|
||||
`, nextStage, stageData, time.Now().UTC(), 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) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3
|
||||
`, stage, time.Now().UTC(), 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
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
|
||||
146
server/store/postgres/channel_helpers.go
Normal file
146
server/store/postgres/channel_helpers.go
Normal file
@@ -0,0 +1,146 @@
|
||||
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
|
||||
}
|
||||
169
server/store/postgres/extension_permissions.go
Normal file
169
server/store/postgres/extension_permissions.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
type ExtensionPermissionStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewExtensionPermissionStore(db *sql.DB) *ExtensionPermissionStore {
|
||||
return &ExtensionPermissionStore{db: db}
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) DeclareForPackage(ctx context.Context, packageID string, permissions []string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Remove permissions no longer in manifest
|
||||
if len(permissions) == 0 {
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM extension_permissions WHERE package_id = $1`, packageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Build set of current declared
|
||||
declared := make(map[string]bool, len(permissions))
|
||||
for _, p := range permissions {
|
||||
declared[p] = true
|
||||
}
|
||||
|
||||
// Get existing
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT permission FROM extension_permissions WHERE package_id = $1`, packageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var perm string
|
||||
if err := rows.Scan(&perm); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
existing[perm] = true
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Delete removed
|
||||
for perm := range existing {
|
||||
if !declared[perm] {
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`DELETE FROM extension_permissions WHERE package_id = $1 AND permission = $2`,
|
||||
packageID, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert new (preserving existing grants)
|
||||
for _, perm := range permissions {
|
||||
if !existing[perm] {
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO extension_permissions (id, package_id, permission)
|
||||
VALUES (gen_random_uuid(), $1, $2)
|
||||
ON CONFLICT (package_id, permission) DO NOTHING`,
|
||||
packageID, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) ListForPackage(ctx context.Context, packageID string) ([]models.ExtensionPermission, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, package_id, permission, granted, granted_by, granted_at, created_at
|
||||
FROM extension_permissions
|
||||
WHERE package_id = $1
|
||||
ORDER BY permission`, packageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var perms []models.ExtensionPermission
|
||||
for rows.Next() {
|
||||
var p models.ExtensionPermission
|
||||
if err := rows.Scan(&p.ID, &p.PackageID, &p.Permission, &p.Granted, &p.GrantedBy, &p.GrantedAt, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
perms = append(perms, p)
|
||||
}
|
||||
if perms == nil {
|
||||
perms = []models.ExtensionPermission{}
|
||||
}
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) GrantedForPackage(ctx context.Context, packageID string) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT permission FROM extension_permissions
|
||||
WHERE package_id = $1 AND granted = true
|
||||
ORDER BY permission`, packageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var perms []string
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
perms = append(perms, p)
|
||||
}
|
||||
if perms == nil {
|
||||
perms = []string{}
|
||||
}
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) Grant(ctx context.Context, packageID, permission, grantedBy string) error {
|
||||
now := time.Now()
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE extension_permissions
|
||||
SET granted = true, granted_by = $1, granted_at = $2
|
||||
WHERE package_id = $3 AND permission = $4`,
|
||||
grantedBy, now, packageID, permission)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) Revoke(ctx context.Context, packageID, permission string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE extension_permissions
|
||||
SET granted = false, granted_by = NULL, granted_at = NULL
|
||||
WHERE package_id = $1 AND permission = $2`,
|
||||
packageID, permission)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) GrantAll(ctx context.Context, packageID, grantedBy string) error {
|
||||
now := time.Now()
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE extension_permissions
|
||||
SET granted = true, granted_by = $1, granted_at = $2
|
||||
WHERE package_id = $3 AND granted = false`,
|
||||
grantedBy, now, packageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) DeleteForPackage(ctx context.Context, packageID string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM extension_permissions WHERE package_id = $1`, packageID)
|
||||
return err
|
||||
}
|
||||
@@ -249,3 +249,12 @@ func (s *FileStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
75
server/store/postgres/folder.go
Normal file
75
server/store/postgres/folder.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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, sort_order)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, parent_id, sort_order, created_at, updated_at
|
||||
`, f.UserID, f.Name, 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) (int64, error) {
|
||||
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
|
||||
}
|
||||
@@ -57,3 +57,40 @@ func (s *GlobalConfigStore) GetAll(ctx context.Context) (map[string]models.JSONM
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── OIDC state (v0.29.0-cs4) ────────────────────────────────────────────
|
||||
|
||||
func (s *GlobalConfigStore) SaveOIDCState(ctx context.Context, state, nonce, redirectTo string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO oidc_auth_state (state, nonce, redirect_to) VALUES ($1, $2, $3)
|
||||
`, state, nonce, redirectTo)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *GlobalConfigStore) ConsumeOIDCState(ctx context.Context, state string) (string, string, error) {
|
||||
var nonce, redirectTo string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT nonce, COALESCE(redirect_to, '') FROM oidc_auth_state WHERE state = $1
|
||||
`, state).Scan(&nonce, &redirectTo)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// Delete (one-time use)
|
||||
_, _ = DB.ExecContext(ctx, `DELETE FROM oidc_auth_state WHERE state = $1`, state)
|
||||
return nonce, redirectTo, nil
|
||||
}
|
||||
|
||||
func (s *GlobalConfigStore) CleanupOIDCState(ctx context.Context) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM oidc_auth_state WHERE created_at < NOW() - INTERVAL '10 minutes'`)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── CS6 additions (v0.29.0) ─────────────────────────────────────────────
|
||||
|
||||
func (s *GlobalConfigStore) GetString(ctx context.Context, key string) (string, error) {
|
||||
var val string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
"SELECT value FROM global_settings WHERE key = $1", key).Scan(&val)
|
||||
return val, err
|
||||
}
|
||||
|
||||
@@ -270,3 +270,34 @@ func scanMemories(rows *sql.Rows) ([]models.Memory, error) {
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
@@ -136,7 +137,9 @@ func (s *MessageStore) GetPathToRoot(ctx context.Context, messageID string) ([]m
|
||||
m.parent_id, m.sibling_index, m.participant_type, m.participant_id, m.deleted_at, m.created_at
|
||||
FROM messages m JOIN path p ON m.id = p.parent_id
|
||||
)
|
||||
SELECT * FROM path ORDER BY created_at ASC`, messageID)
|
||||
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
|
||||
}
|
||||
@@ -189,3 +192,115 @@ func scanMessages(rows *sql.Rows) ([]models.Message, error) {
|
||||
}
|
||||
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(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
|
||||
}
|
||||
383
server/store/postgres/message_tree.go
Normal file
383
server/store/postgres/message_tree.go
Normal file
@@ -0,0 +1,383 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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
|
||||
}
|
||||
@@ -219,3 +219,100 @@ func (s *NoteStore) SearchTitles(ctx context.Context, userID, query string, limi
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -51,6 +51,20 @@ func (s *PackageStore) SetEnabled(ctx context.Context, id string, enabled bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) SetStatus(ctx context.Context, id string, status string) error {
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`UPDATE packages SET status = $2, updated_at = NOW() WHERE id = $1`,
|
||||
id, status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) Delete(ctx context.Context, id string) error {
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM packages WHERE id = $1 AND source != 'core'`, id)
|
||||
@@ -87,15 +101,18 @@ func (s *PackageStore) ListEnabled(ctx context.Context) ([]string, error) {
|
||||
|
||||
func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistration) error {
|
||||
manifestJSON := ToJSON(pkg.Manifest)
|
||||
if pkg.Status == "" {
|
||||
pkg.Status = "active"
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO packages (id, title, type, version, description, author, tier,
|
||||
is_system, scope, team_id, installed_by, manifest, enabled, source)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
is_system, scope, team_id, installed_by, manifest, enabled, status, source)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
RETURNING installed_at, updated_at`,
|
||||
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
||||
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
||||
manifestJSON, pkg.Enabled, pkg.Source,
|
||||
manifestJSON, pkg.Enabled, pkg.Status, pkg.Source,
|
||||
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
|
||||
}
|
||||
|
||||
@@ -155,7 +172,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
||||
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
|
||||
&up.Author, &up.Tier, &up.IsSystem, &up.Scope,
|
||||
&teamID, &installedBy,
|
||||
&manifestJSON, &up.Enabled, &up.Source,
|
||||
&manifestJSON, &up.Enabled, &up.Status, &up.Source,
|
||||
&up.InstalledAt, &up.UpdatedAt,
|
||||
&userEnabled, &userSettings,
|
||||
); err != nil {
|
||||
@@ -218,7 +235,7 @@ func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID str
|
||||
// so column additions don't silently break positional Scan().
|
||||
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
||||
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
||||
p.manifest, p.enabled, p.source, p.installed_at, p.updated_at`
|
||||
p.manifest, p.enabled, p.status, p.source, p.installed_at, p.updated_at`
|
||||
|
||||
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
||||
var pkg store.PackageRegistration
|
||||
@@ -228,7 +245,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
|
||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
|
||||
&teamID, &installedBy,
|
||||
&manifestJSON, &pkg.Enabled, &pkg.Source,
|
||||
&manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.Source,
|
||||
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -259,7 +276,7 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
|
||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
|
||||
&teamID, &installedBy,
|
||||
&manifestJSON, &pkg.Enabled, &pkg.Source,
|
||||
&manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.Source,
|
||||
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
138
server/store/postgres/persona_group.go
Normal file
138
server/store/postgres/persona_group.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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()
|
||||
}
|
||||
80
server/store/postgres/persona_mention.go
Normal file
80
server/store/postgres/persona_mention.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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
|
||||
}
|
||||
50
server/store/postgres/presence.go
Normal file
50
server/store/postgres/presence.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PresenceStore manages user_presence table.
|
||||
type PresenceStore struct{}
|
||||
|
||||
func NewPresenceStore() *PresenceStore { return &PresenceStore{} }
|
||||
|
||||
func (s *PresenceStore) Heartbeat(ctx context.Context, userID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO user_presence (user_id, last_seen, status)
|
||||
VALUES ($1, NOW(), 'online')
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET last_seen = NOW(), status = 'online'
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PresenceStore) GetLastSeen(ctx context.Context, userID string) (*time.Time, error) {
|
||||
var lastSeen time.Time
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT last_seen FROM user_presence WHERE user_id = $1`, userID).Scan(&lastSeen)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lastSeen, nil
|
||||
}
|
||||
|
||||
func (s *PresenceStore) GetStatuses(ctx context.Context, userIDs []string, threshold time.Time) (map[string]string, error) {
|
||||
result := make(map[string]string, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
var lastSeen time.Time
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT last_seen FROM user_presence WHERE user_id = $1`, id).Scan(&lastSeen)
|
||||
if err != nil || lastSeen.Before(threshold) {
|
||||
result[id] = "offline"
|
||||
} else {
|
||||
result[id] = "online"
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/lib/pq"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── ProjectStore ───────────────────────────
|
||||
@@ -395,6 +396,45 @@ func (s *ProjectStore) GetProjectIDForChannel(ctx context.Context, channelID str
|
||||
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) {
|
||||
|
||||
@@ -195,3 +195,64 @@ func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -42,5 +42,10 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Packages: NewPackageStore(),
|
||||
Workflows: NewWorkflowStore(),
|
||||
Tasks: NewTaskStore(),
|
||||
Presence: NewPresenceStore(),
|
||||
PersonaGroups: NewPersonaGroupStore(),
|
||||
Folders: NewFolderStore(),
|
||||
Health: NewHealthStore(db),
|
||||
ExtPermissions: NewExtensionPermissionStore(db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,3 +226,101 @@ func (s *TeamStore) IsMember(ctx context.Context, teamID, userID string) (bool,
|
||||
|
||||
// unused but keeping for reference
|
||||
var _ = fmt.Sprintf
|
||||
|
||||
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────
|
||||
|
||||
func (s *TeamStore) Exists(ctx context.Context, teamID string) (bool, error) {
|
||||
var exists bool
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM teams WHERE id = $1)`, teamID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *TeamStore) UpdateMemberRoleByID(ctx context.Context, memberID, teamID, role string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx,
|
||||
`UPDATE team_members SET role = $1 WHERE id = $2 AND team_id = $3`,
|
||||
role, memberID, teamID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *TeamStore) DeleteMemberByID(ctx context.Context, memberID, teamID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM team_members WHERE id = $1 AND team_id = $2`,
|
||||
memberID, teamID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *TeamStore) ListTeamAuditActions(ctx context.Context, teamID string) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT DISTINCT al.action
|
||||
FROM audit_log al
|
||||
WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1)
|
||||
ORDER BY al.action
|
||||
`, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var actions []string
|
||||
for rows.Next() {
|
||||
var a string
|
||||
if err := rows.Scan(&a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions = append(actions, a)
|
||||
}
|
||||
if actions == nil {
|
||||
actions = []string{}
|
||||
}
|
||||
return actions, rows.Err()
|
||||
}
|
||||
|
||||
// ── CS5b additions (v0.29.0) ────────────────────────────────────────────
|
||||
|
||||
func (s *TeamStore) GetFirstTeamIDForUser(ctx context.Context, userID string) (string, error) {
|
||||
var teamID string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT team_id FROM team_members WHERE user_id = $1 LIMIT 1`, userID).Scan(&teamID)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
return teamID, nil
|
||||
}
|
||||
|
||||
// ── CS6 additions (v0.29.0) ────────────────────────────────────────────
|
||||
|
||||
func (s *TeamStore) AddMemberReturningID(ctx context.Context, teamID, userID, role string) (string, error) {
|
||||
var id string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
INSERT INTO team_members (team_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id`, teamID, userID, role).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *TeamStore) HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error) {
|
||||
var has bool
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM team_members tm
|
||||
JOIN teams t ON t.id = tm.team_id
|
||||
WHERE tm.user_id = $1
|
||||
AND t.is_active = true
|
||||
AND t.settings->>'require_private_providers' = 'true'
|
||||
)`, userID).Scan(&has)
|
||||
return has, err
|
||||
}
|
||||
|
||||
func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE teams SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb WHERE id = $2`,
|
||||
settingsJSON, teamID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
@@ -186,3 +187,110 @@ func scanOneUser(ctx context.Context, query string, args ...interface{}) (*model
|
||||
ScanJSON(sj, &u.Settings)
|
||||
return &u, nil
|
||||
}
|
||||
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────
|
||||
|
||||
func (s *UserStore) Exists(ctx context.Context, userID string) (bool, error) {
|
||||
var exists bool
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, userID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query string) ([]store.UserSearchResult, error) {
|
||||
q := `
|
||||
SELECT id, username, COALESCE(display_name, '') AS display_name, COALESCE(handle, '') AS handle
|
||||
FROM users
|
||||
WHERE is_active = true AND id != $1`
|
||||
args := []interface{}{excludeUserID}
|
||||
|
||||
if query != "" {
|
||||
q += ` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3 OR LOWER(handle) LIKE $4)`
|
||||
pattern := "%" + strings.ToLower(query) + "%"
|
||||
args = append(args, pattern, pattern, pattern)
|
||||
}
|
||||
|
||||
q += ` ORDER BY username LIMIT 20`
|
||||
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []store.UserSearchResult
|
||||
for rows.Next() {
|
||||
var u store.UserSearchResult
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, u)
|
||||
}
|
||||
if results == nil {
|
||||
results = []store.UserSearchResult{}
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
|
||||
|
||||
func (s *UserStore) CountByRole(ctx context.Context, role string) (int, error) {
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE role = $1`, role).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE users SET settings = (
|
||||
CASE WHEN settings IS NULL OR settings = 'null'::jsonb OR jsonb_typeof(settings) != 'object'
|
||||
THEN '{}'::jsonb ELSE settings END
|
||||
) || $1::jsonb, updated_at = NOW() WHERE id = $2
|
||||
`, string(patch), userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *UserStore) GetVaultKeys(ctx context.Context, userID string) (bool, []byte, []byte, []byte, error) {
|
||||
var vaultSet bool
|
||||
var encUEK, salt, nonce []byte
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(&vaultSet, &encUEK, &salt, &nonce)
|
||||
return vaultSet, encUEK, salt, nonce, err
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdateVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, updated_at = NOW()
|
||||
WHERE id = $4
|
||||
`, encUEK, salt, nonce, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *UserStore) CountAll(ctx context.Context) (int, error) {
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ── CS4 additions (v0.29.0) ─────────────────────────────────────────────
|
||||
|
||||
func (s *UserStore) ClearVaultKeys(ctx context.Context, userID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *UserStore) InitVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, vault_set = true
|
||||
WHERE id = $4
|
||||
`, encUEK, salt, nonce, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
62
server/store/postgres/user_mention.go
Normal file
62
server/store/postgres/user_mention.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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
|
||||
}
|
||||
@@ -2,10 +2,13 @@ package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// WorkflowStore implements store.WorkflowStore for Postgres.
|
||||
@@ -357,3 +360,129 @@ func nullIfEmpty(s string) interface{} {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ── Assignments (v0.29.0-cs3) ───────────────────────────────────────────
|
||||
|
||||
func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *store.WorkflowAssignment) error {
|
||||
a.ID = store.NewID()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_assignments (id, channel_id, stage, team_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, a.ID, a.ChannelID, a.Stage, a.TeamID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListAssignmentsForTeam(ctx context.Context, teamID, status string) ([]store.WorkflowAssignment, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments
|
||||
WHERE team_id = $1 AND status = $2
|
||||
ORDER BY created_at DESC
|
||||
`, teamID, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAssignments(rows)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListAssignmentsMine(ctx context.Context, userID string) ([]store.WorkflowAssignment, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT DISTINCT wa.id, wa.channel_id, wa.stage, wa.team_id, wa.assigned_to, wa.status,
|
||||
wa.created_at, wa.claimed_at, wa.completed_at
|
||||
FROM workflow_assignments wa
|
||||
LEFT JOIN team_members tm ON tm.team_id = wa.team_id AND tm.user_id = $1
|
||||
WHERE (wa.assigned_to = $2 AND wa.status = 'claimed')
|
||||
OR (wa.status = 'unassigned' AND tm.user_id IS NOT NULL)
|
||||
ORDER BY wa.created_at DESC
|
||||
`, userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAssignments(rows)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ClaimAssignment(ctx context.Context, assignmentID, userID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = $1, status = 'claimed', claimed_at = $2
|
||||
WHERE id = $3 AND status = 'unassigned'
|
||||
`, userID, time.Now().UTC(), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) CompleteAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'completed', completed_at = $1
|
||||
WHERE id = $2 AND status = 'claimed'
|
||||
`, time.Now().UTC(), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID string) (string, error) {
|
||||
var channelID string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT channel_id FROM workflow_assignments WHERE id = $1`, assignmentID).Scan(&channelID)
|
||||
return channelID, err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) {
|
||||
// Find least-recently-assigned team member
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01T00:00:00Z') as last_claim
|
||||
FROM team_members m
|
||||
LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1
|
||||
WHERE m.team_id = $2
|
||||
GROUP BY m.user_id
|
||||
ORDER BY last_claim ASC
|
||||
LIMIT 1
|
||||
`, teamID, teamID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
return "", nil // no team members
|
||||
}
|
||||
var userID, lastClaim string
|
||||
if err := rows.Scan(&userID, &lastClaim); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Claim for that user
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = $1, status = 'claimed', claimed_at = $2
|
||||
WHERE id = $3 AND status = 'unassigned'
|
||||
`, userID, time.Now().UTC(), assignmentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
|
||||
var result []store.WorkflowAssignment
|
||||
for rows.Next() {
|
||||
var a store.WorkflowAssignment
|
||||
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
|
||||
&a.AssignedTo, &a.Status, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, a)
|
||||
}
|
||||
if result == nil {
|
||||
result = []store.WorkflowAssignment{}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
@@ -44,4 +45,26 @@ type ProjectStore interface {
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ func (s *AuditStore) List(ctx context.Context, opts store.AuditListOptions) ([]m
|
||||
al.ip_address, al.user_agent, al.created_at`, "audit_log al")
|
||||
b.Join("LEFT JOIN users u ON al.actor_id = u.id")
|
||||
|
||||
if opts.TeamID != "" {
|
||||
b.Where("al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = ?)", opts.TeamID)
|
||||
}
|
||||
if opts.ActorID != "" {
|
||||
b.Where("al.actor_id = ?", opts.ActorID)
|
||||
}
|
||||
|
||||
@@ -276,3 +276,58 @@ func scanCatalogEntries(rows *sql.Rows) ([]models.CatalogEntry, error) {
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
57
server/store/sqlite/catalog_mention.go
Normal file
57
server/store/sqlite/catalog_mention.go
Normal file
@@ -0,0 +1,57 @@
|
||||
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
|
||||
}
|
||||
@@ -477,3 +477,427 @@ func (s *ChannelStore) Purge(ctx context.Context, id string) error {
|
||||
_, 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) 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 {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET workflow_id = ?, workflow_version = ?, current_stage = 0,
|
||||
stage_data = ?, workflow_status = ?, last_activity_at = ?
|
||||
WHERE id = ?
|
||||
`, workflowID, version, stageData, status, time.Now().UTC().Format(timeFmt), 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
|
||||
FROM channels WHERE id = ? AND type = 'workflow'
|
||||
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
|
||||
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 {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET current_stage = ?, stage_data = ?, last_activity_at = ?
|
||||
WHERE id = ?
|
||||
`, nextStage, stageData, time.Now().UTC().Format(timeFmt), 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) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels SET current_stage = ?, last_activity_at = ? WHERE id = ?
|
||||
`, stage, time.Now().UTC().Format(timeFmt), 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
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
|
||||
143
server/store/sqlite/channel_helpers.go
Normal file
143
server/store/sqlite/channel_helpers.go
Normal file
@@ -0,0 +1,143 @@
|
||||
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
|
||||
}
|
||||
164
server/store/sqlite/extension_permissions.go
Normal file
164
server/store/sqlite/extension_permissions.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
type ExtensionPermissionStore struct{}
|
||||
|
||||
func NewExtensionPermissionStore() *ExtensionPermissionStore {
|
||||
return &ExtensionPermissionStore{}
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) DeclareForPackage(ctx context.Context, packageID string, permissions []string) error {
|
||||
tx, err := DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if len(permissions) == 0 {
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM extension_permissions WHERE package_id = ?`, packageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
declared := make(map[string]bool, len(permissions))
|
||||
for _, p := range permissions {
|
||||
declared[p] = true
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT permission FROM extension_permissions WHERE package_id = ?`, packageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var perm string
|
||||
if err := rows.Scan(&perm); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
existing[perm] = true
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for perm := range existing {
|
||||
if !declared[perm] {
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`DELETE FROM extension_permissions WHERE package_id = ? AND permission = ?`,
|
||||
packageID, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, perm := range permissions {
|
||||
if !existing[perm] {
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO extension_permissions (id, package_id, permission)
|
||||
VALUES (?, ?, ?)`,
|
||||
uuid.New().String(), packageID, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) ListForPackage(ctx context.Context, packageID string) ([]models.ExtensionPermission, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT id, package_id, permission, granted, granted_by, granted_at, created_at
|
||||
FROM extension_permissions
|
||||
WHERE package_id = ?
|
||||
ORDER BY permission`, packageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var perms []models.ExtensionPermission
|
||||
for rows.Next() {
|
||||
var p models.ExtensionPermission
|
||||
if err := rows.Scan(&p.ID, &p.PackageID, &p.Permission, &p.Granted,
|
||||
&p.GrantedBy, database.SNT(&p.GrantedAt), database.ST(&p.CreatedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
perms = append(perms, p)
|
||||
}
|
||||
if perms == nil {
|
||||
perms = []models.ExtensionPermission{}
|
||||
}
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) GrantedForPackage(ctx context.Context, packageID string) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT permission FROM extension_permissions
|
||||
WHERE package_id = ? AND granted = 1
|
||||
ORDER BY permission`, packageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var perms []string
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
perms = append(perms, p)
|
||||
}
|
||||
if perms == nil {
|
||||
perms = []string{}
|
||||
}
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) Grant(ctx context.Context, packageID, permission, grantedBy string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE extension_permissions
|
||||
SET granted = 1, granted_by = ?, granted_at = ?
|
||||
WHERE package_id = ? AND permission = ?`,
|
||||
grantedBy, now, packageID, permission)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) Revoke(ctx context.Context, packageID, permission string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE extension_permissions
|
||||
SET granted = 0, granted_by = NULL, granted_at = NULL
|
||||
WHERE package_id = ? AND permission = ?`,
|
||||
packageID, permission)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) GrantAll(ctx context.Context, packageID, grantedBy string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE extension_permissions
|
||||
SET granted = 1, granted_by = ?, granted_at = ?
|
||||
WHERE package_id = ? AND granted = 0`,
|
||||
grantedBy, now, packageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionPermissionStore) DeleteForPackage(ctx context.Context, packageID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM extension_permissions WHERE package_id = ?`, packageID)
|
||||
return err
|
||||
}
|
||||
@@ -254,3 +254,12 @@ func (s *FileStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
82
server/store/sqlite/folder.go
Normal file
82
server/store/sqlite/folder.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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, sort_order)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, f.ID, f.UserID, f.Name, 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) (int64, error) {
|
||||
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
|
||||
}
|
||||
@@ -56,3 +56,39 @@ func (s *GlobalConfigStore) GetAll(ctx context.Context) (map[string]models.JSONM
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── OIDC state (v0.29.0-cs4) ────────────────────────────────────────────
|
||||
|
||||
func (s *GlobalConfigStore) SaveOIDCState(ctx context.Context, state, nonce, redirectTo string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO oidc_auth_state (state, nonce, redirect_to) VALUES (?, ?, ?)
|
||||
`, state, nonce, redirectTo)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *GlobalConfigStore) ConsumeOIDCState(ctx context.Context, state string) (string, string, error) {
|
||||
var nonce, redirectTo string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT nonce, COALESCE(redirect_to, '') FROM oidc_auth_state WHERE state = ?
|
||||
`, state).Scan(&nonce, &redirectTo)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
_, _ = DB.ExecContext(ctx, `DELETE FROM oidc_auth_state WHERE state = ?`, state)
|
||||
return nonce, redirectTo, nil
|
||||
}
|
||||
|
||||
func (s *GlobalConfigStore) CleanupOIDCState(ctx context.Context) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM oidc_auth_state WHERE created_at < datetime('now', '-10 minutes')`)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── CS6 additions (v0.29.0) ─────────────────────────────────────────────
|
||||
|
||||
func (s *GlobalConfigStore) GetString(ctx context.Context, key string) (string, error) {
|
||||
var val string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
"SELECT value FROM global_settings WHERE key = ?", key).Scan(&val)
|
||||
return val, err
|
||||
}
|
||||
|
||||
@@ -254,3 +254,34 @@ func (s *MemoryStore) scanMemories(rows *sql.Rows) ([]models.Memory, error) {
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
@@ -137,7 +138,9 @@ func (s *MessageStore) GetPathToRoot(ctx context.Context, messageID string) ([]m
|
||||
m.parent_id, m.sibling_index, m.participant_type, m.participant_id, m.deleted_at, m.created_at
|
||||
FROM messages m JOIN path p ON m.id = p.parent_id
|
||||
)
|
||||
SELECT * FROM path ORDER BY created_at ASC`, messageID)
|
||||
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
|
||||
}
|
||||
@@ -188,3 +191,120 @@ func scanMessages(rows *sql.Rows) ([]models.Message, error) {
|
||||
}
|
||||
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(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
|
||||
}
|
||||
382
server/store/sqlite/message_tree.go
Normal file
382
server/store/sqlite/message_tree.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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
|
||||
}
|
||||
@@ -226,3 +226,80 @@ func (s *NoteStore) scanNotes(rows *sql.Rows, total int) ([]models.Note, int, er
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -55,6 +55,20 @@ func (s *PackageStore) SetEnabled(ctx context.Context, id string, enabled bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) SetStatus(ctx context.Context, id string, status string) error {
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`UPDATE packages SET status = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
status, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PackageStore) Delete(ctx context.Context, id string) error {
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM packages WHERE id = ? AND source != 'core'`, id)
|
||||
@@ -92,15 +106,18 @@ func (s *PackageStore) ListEnabled(ctx context.Context) ([]string, error) {
|
||||
func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistration) error {
|
||||
now := time.Now().UTC()
|
||||
manifestJSON := ToJSON(pkg.Manifest)
|
||||
if pkg.Status == "" {
|
||||
pkg.Status = "active"
|
||||
}
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO packages (id, title, type, version, description, author, tier,
|
||||
is_system, scope, team_id, installed_by, manifest, enabled, source,
|
||||
is_system, scope, team_id, installed_by, manifest, enabled, status, source,
|
||||
installed_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
||||
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
||||
manifestJSON, pkg.Enabled, pkg.Source,
|
||||
manifestJSON, pkg.Enabled, pkg.Status, pkg.Source,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -168,7 +185,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
||||
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
|
||||
&up.Author, &up.Tier, &isSystemInt, &up.Scope,
|
||||
&teamID, &installedBy,
|
||||
&manifestJSON, &enabledInt, &up.Source,
|
||||
&manifestJSON, &enabledInt, &up.Status, &up.Source,
|
||||
&up.InstalledAt, &up.UpdatedAt,
|
||||
&userEnabled, &userSettings,
|
||||
); err != nil {
|
||||
@@ -231,7 +248,7 @@ func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID str
|
||||
|
||||
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
||||
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
||||
p.manifest, p.enabled, p.source, p.installed_at, p.updated_at`
|
||||
p.manifest, p.enabled, p.status, p.source, p.installed_at, p.updated_at`
|
||||
|
||||
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
||||
var pkg store.PackageRegistration
|
||||
@@ -243,7 +260,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
|
||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
||||
&teamID, &installedBy,
|
||||
&manifestJSON, &enabledInt, &pkg.Source,
|
||||
&manifestJSON, &enabledInt, &pkg.Status, &pkg.Source,
|
||||
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -278,7 +295,7 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
|
||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
||||
&teamID, &installedBy,
|
||||
&manifestJSON, &enabledInt, &pkg.Source,
|
||||
&manifestJSON, &enabledInt, &pkg.Status, &pkg.Source,
|
||||
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
144
server/store/sqlite/persona_group.go
Normal file
144
server/store/sqlite/persona_group.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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()
|
||||
}
|
||||
80
server/store/sqlite/persona_mention.go
Normal file
80
server/store/sqlite/persona_mention.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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
|
||||
}
|
||||
55
server/store/sqlite/presence.go
Normal file
55
server/store/sqlite/presence.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// PresenceStore manages user_presence table.
|
||||
type PresenceStore struct{}
|
||||
|
||||
func NewPresenceStore() *PresenceStore { return &PresenceStore{} }
|
||||
|
||||
func (s *PresenceStore) Heartbeat(ctx context.Context, userID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO user_presence (user_id, last_seen, status)
|
||||
VALUES (?, datetime('now'), 'online')
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET last_seen = datetime('now'), status = 'online'
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PresenceStore) GetLastSeen(ctx context.Context, userID string) (*time.Time, error) {
|
||||
var lastSeen time.Time
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT last_seen FROM user_presence WHERE user_id = ?`, userID).Scan(st(&lastSeen))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lastSeen, nil
|
||||
}
|
||||
|
||||
func (s *PresenceStore) GetStatuses(ctx context.Context, userIDs []string, threshold time.Time) (map[string]string, error) {
|
||||
result := make(map[string]string, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
var lastSeen time.Time
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT last_seen FROM user_presence WHERE user_id = ?`, id).Scan(st(&lastSeen))
|
||||
if err != nil || lastSeen.Before(threshold) {
|
||||
result[id] = "offline"
|
||||
} else {
|
||||
result[id] = "online"
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Ensure PresenceStore satisfies the interface at compile time.
|
||||
var _ store.PresenceStore = (*PresenceStore)(nil)
|
||||
@@ -418,6 +418,52 @@ func (s *ProjectStore) GetProjectIDForChannel(ctx context.Context, channelID str
|
||||
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 {
|
||||
|
||||
@@ -203,3 +203,64 @@ func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -42,5 +42,10 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Packages: NewPackageStore(),
|
||||
Workflows: NewWorkflowStore(),
|
||||
Tasks: NewTaskStore(),
|
||||
Presence: NewPresenceStore(),
|
||||
PersonaGroups: NewPersonaGroupStore(),
|
||||
Folders: NewFolderStore(),
|
||||
Health: NewHealthStore(),
|
||||
ExtPermissions: NewExtensionPermissionStore(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,3 +233,100 @@ func (s *TeamStore) IsMember(ctx context.Context, teamID, userID string) (bool,
|
||||
|
||||
// unused but keeping for reference
|
||||
var _ = fmt.Sprintf
|
||||
|
||||
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────
|
||||
|
||||
func (s *TeamStore) Exists(ctx context.Context, teamID string) (bool, error) {
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM teams WHERE id = ?`, teamID).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (s *TeamStore) UpdateMemberRoleByID(ctx context.Context, memberID, teamID, role string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx,
|
||||
`UPDATE team_members SET role = ? WHERE id = ? AND team_id = ?`,
|
||||
role, memberID, teamID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *TeamStore) DeleteMemberByID(ctx context.Context, memberID, teamID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM team_members WHERE id = ? AND team_id = ?`,
|
||||
memberID, teamID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *TeamStore) ListTeamAuditActions(ctx context.Context, teamID string) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT DISTINCT al.action
|
||||
FROM audit_log al
|
||||
WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = ?)
|
||||
ORDER BY al.action
|
||||
`, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var actions []string
|
||||
for rows.Next() {
|
||||
var a string
|
||||
if err := rows.Scan(&a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions = append(actions, a)
|
||||
}
|
||||
if actions == nil {
|
||||
actions = []string{}
|
||||
}
|
||||
return actions, rows.Err()
|
||||
}
|
||||
|
||||
// ── CS5b additions (v0.29.0) ────────────────────────────────────────────
|
||||
|
||||
func (s *TeamStore) GetFirstTeamIDForUser(ctx context.Context, userID string) (string, error) {
|
||||
var teamID string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT team_id FROM team_members WHERE user_id = ? LIMIT 1`, userID).Scan(&teamID)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
return teamID, nil
|
||||
}
|
||||
|
||||
// ── CS6 additions (v0.29.0) ────────────────────────────────────────────
|
||||
|
||||
func (s *TeamStore) AddMemberReturningID(ctx context.Context, teamID, userID, role string) (string, error) {
|
||||
id := store.NewID()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO team_members (id, team_id, user_id, role)
|
||||
VALUES (?, ?, ?, ?)`, id, teamID, userID, role)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *TeamStore) HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error) {
|
||||
var has bool
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM team_members tm
|
||||
JOIN teams t ON t.id = tm.team_id
|
||||
WHERE tm.user_id = ?
|
||||
AND t.is_active = 1
|
||||
AND json_extract(t.settings, '$.require_private_providers') = 'true'
|
||||
)`, userID).Scan(&has)
|
||||
return has, err
|
||||
}
|
||||
|
||||
func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE teams SET settings = json_patch(COALESCE(settings, '{}'), ?) WHERE id = ?`,
|
||||
settingsJSON, teamID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
@@ -193,3 +194,110 @@ func (s *UserStore) scanOne(ctx context.Context, query string, args ...interface
|
||||
ScanJSON(sj, &u.Settings)
|
||||
return &u, nil
|
||||
}
|
||||
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────
|
||||
|
||||
func (s *UserStore) Exists(ctx context.Context, userID string) (bool, error) {
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE id = ?`, userID).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query string) ([]store.UserSearchResult, error) {
|
||||
q := `
|
||||
SELECT id, username, COALESCE(display_name, '') AS display_name, COALESCE(handle, '') AS handle
|
||||
FROM users
|
||||
WHERE is_active = 1 AND id != ?`
|
||||
args := []interface{}{excludeUserID}
|
||||
|
||||
if query != "" {
|
||||
q += ` AND (LOWER(username) LIKE ? OR LOWER(display_name) LIKE ? OR LOWER(handle) LIKE ?)`
|
||||
pattern := "%" + strings.ToLower(query) + "%"
|
||||
args = append(args, pattern, pattern, pattern)
|
||||
}
|
||||
|
||||
q += ` ORDER BY username LIMIT 20`
|
||||
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []store.UserSearchResult
|
||||
for rows.Next() {
|
||||
var u store.UserSearchResult
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, u)
|
||||
}
|
||||
if results == nil {
|
||||
results = []store.UserSearchResult{}
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
|
||||
|
||||
func (s *UserStore) CountByRole(ctx context.Context, role string) (int, error) {
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE role = ?`, role).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE users SET settings = json_patch(
|
||||
CASE WHEN settings IS NULL OR settings = 'null' OR json_type(settings) != 'object'
|
||||
THEN '{}' ELSE settings END,
|
||||
?), updated_at = datetime('now') WHERE id = ?
|
||||
`, string(patch), userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *UserStore) GetVaultKeys(ctx context.Context, userID string) (bool, []byte, []byte, []byte, error) {
|
||||
var vaultSet bool
|
||||
var encUEK, salt, nonce []byte
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
|
||||
FROM users WHERE id = ?
|
||||
`, userID).Scan(&vaultSet, &encUEK, &salt, &nonce)
|
||||
return vaultSet, encUEK, salt, nonce, err
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdateVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET encrypted_uek = ?, uek_salt = ?, uek_nonce = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, encUEK, salt, nonce, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *UserStore) CountAll(ctx context.Context) (int, error) {
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ── CS4 additions (v0.29.0) ─────────────────────────────────────────────
|
||||
|
||||
func (s *UserStore) ClearVaultKeys(ctx context.Context, userID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = 0
|
||||
WHERE id = ?
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *UserStore) InitVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET encrypted_uek = ?, uek_salt = ?, uek_nonce = ?, vault_set = 1
|
||||
WHERE id = ?
|
||||
`, encUEK, salt, nonce, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
62
server/store/sqlite/user_mention.go
Normal file
62
server/store/sqlite/user_mention.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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
|
||||
}
|
||||
@@ -364,3 +364,130 @@ func nullIfEmpty(s string) interface{} {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ── Assignments (v0.29.0-cs3) ───────────────────────────────────────────
|
||||
|
||||
func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *store.WorkflowAssignment) error {
|
||||
a.ID = store.NewID()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_assignments (id, channel_id, stage, team_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, a.ID, a.ChannelID, a.Stage, a.TeamID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListAssignmentsForTeam(ctx context.Context, teamID, status string) ([]store.WorkflowAssignment, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments
|
||||
WHERE team_id = ? AND status = ?
|
||||
ORDER BY created_at DESC
|
||||
`, teamID, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAssignments(rows)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListAssignmentsMine(ctx context.Context, userID string) ([]store.WorkflowAssignment, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT DISTINCT wa.id, wa.channel_id, wa.stage, wa.team_id, wa.assigned_to, wa.status,
|
||||
wa.created_at, wa.claimed_at, wa.completed_at
|
||||
FROM workflow_assignments wa
|
||||
LEFT JOIN team_members tm ON tm.team_id = wa.team_id AND tm.user_id = ?
|
||||
WHERE (wa.assigned_to = ? AND wa.status = 'claimed')
|
||||
OR (wa.status = 'unassigned' AND tm.user_id IS NOT NULL)
|
||||
ORDER BY wa.created_at DESC
|
||||
`, userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAssignments(rows)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ClaimAssignment(ctx context.Context, assignmentID, userID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = ?, status = 'claimed', claimed_at = ?
|
||||
WHERE id = ? AND status = 'unassigned'
|
||||
`, userID, time.Now().UTC().Format(timeFmt), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) CompleteAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'completed', completed_at = ?
|
||||
WHERE id = ? AND status = 'claimed'
|
||||
`, time.Now().UTC().Format(timeFmt), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID string) (string, error) {
|
||||
var channelID string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT channel_id FROM workflow_assignments WHERE id = ?`, assignmentID).Scan(&channelID)
|
||||
return channelID, err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01 00:00:00') as last_claim
|
||||
FROM team_members m
|
||||
LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = ?
|
||||
WHERE m.team_id = ?
|
||||
GROUP BY m.user_id
|
||||
ORDER BY last_claim ASC
|
||||
LIMIT 1
|
||||
`, teamID, teamID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
return "", nil
|
||||
}
|
||||
var userID, lastClaim string
|
||||
if err := rows.Scan(&userID, &lastClaim); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = ?, status = 'claimed', claimed_at = ?
|
||||
WHERE id = ? AND status = 'unassigned'
|
||||
`, userID, time.Now().UTC().Format(timeFmt), assignmentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
|
||||
var result []store.WorkflowAssignment
|
||||
for rows.Next() {
|
||||
var a store.WorkflowAssignment
|
||||
var claimedAt, completedAt *time.Time
|
||||
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
|
||||
&a.AssignedTo, &a.Status, st(&a.CreatedAt), stN(&claimedAt), stN(&completedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ClaimedAt = claimedAt
|
||||
a.CompletedAt = completedAt
|
||||
result = append(result, a)
|
||||
}
|
||||
if result == nil {
|
||||
result = []store.WorkflowAssignment{}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
@@ -52,4 +52,16 @@ type MemoryStore interface {
|
||||
// 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
|
||||
}
|
||||
|
||||
54
server/store/tree_types.go
Normal file
54
server/store/tree_types.go
Normal file
@@ -0,0 +1,54 @@
|
||||
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
|
||||
}
|
||||
@@ -28,4 +28,30 @@ type WorkflowStore interface {
|
||||
Publish(ctx context.Context, v *models.WorkflowVersion) error
|
||||
GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error)
|
||||
GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error)
|
||||
|
||||
// ── Assignments (v0.29.0-cs3) ──
|
||||
|
||||
// CreateAssignment inserts a workflow_assignments row.
|
||||
CreateAssignment(ctx context.Context, a *WorkflowAssignment) error
|
||||
|
||||
// ListAssignmentsForTeam returns assignments for a team filtered by status.
|
||||
ListAssignmentsForTeam(ctx context.Context, teamID, status string) ([]WorkflowAssignment, error)
|
||||
|
||||
// ListAssignmentsMine returns claimed + unassigned assignments visible to a user.
|
||||
ListAssignmentsMine(ctx context.Context, userID string) ([]WorkflowAssignment, error)
|
||||
|
||||
// ClaimAssignment sets assigned_to and status='claimed' on an unassigned row.
|
||||
// Returns rows affected (0 = already claimed or not found).
|
||||
ClaimAssignment(ctx context.Context, assignmentID, userID string) (int64, error)
|
||||
|
||||
// CompleteAssignment sets status='completed' on a claimed row.
|
||||
// Returns rows affected (0 = not claimed or not found).
|
||||
CompleteAssignment(ctx context.Context, assignmentID string) (int64, error)
|
||||
|
||||
// GetAssignmentChannelID returns the channel_id for an assignment.
|
||||
GetAssignmentChannelID(ctx context.Context, assignmentID string) (string, error)
|
||||
|
||||
// TryRoundRobin finds the least-recently-assigned team member and claims.
|
||||
// Returns the assigned user ID, or "" if no members available.
|
||||
TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error)
|
||||
}
|
||||
|
||||
29
server/store/workflow_types.go
Normal file
29
server/store/workflow_types.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkflowChannelStatus holds the workflow state columns from the channels table.
|
||||
type WorkflowChannelStatus struct {
|
||||
WorkflowID *string `json:"workflow_id"`
|
||||
WorkflowVersion *int `json:"workflow_version"`
|
||||
CurrentStage int `json:"current_stage"`
|
||||
StageData json.RawMessage `json:"stage_data"`
|
||||
Status string `json:"status"`
|
||||
LastActivityAt *string `json:"last_activity_at"`
|
||||
}
|
||||
|
||||
// WorkflowAssignment is a row from the workflow_assignments table.
|
||||
type WorkflowAssignment struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Stage int `json:"stage"`
|
||||
TeamID string `json:"team_id"`
|
||||
AssignedTo *string `json:"assigned_to"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ClaimedAt *time.Time `json:"claimed_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
}
|
||||
Reference in New Issue
Block a user