- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
1434 lines
51 KiB
Go
1434 lines
51 KiB
Go
package postgres
|
|
|
|
// export.go — v0.34.0 CS0
|
|
//
|
|
// ExportStore: batch-read queries for data export and GDPR operations.
|
|
// Uses the shared package-level DB variable (set by SetDB in stores.go).
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/lib/pq"
|
|
|
|
"switchboard-core/models"
|
|
)
|
|
|
|
// ExportStore implements store.ExportStore for Postgres.
|
|
type ExportStore struct{}
|
|
|
|
// NewExportStore creates a Postgres ExportStore.
|
|
func NewExportStore() *ExportStore { return &ExportStore{} }
|
|
|
|
// ── helpers ──────────────────────────────────
|
|
|
|
// placeholders builds "$1,$2,...,$n" and a matching []any slice for IN queries.
|
|
func placeholders(ids []string) (string, []any) {
|
|
ph := make([]string, len(ids))
|
|
args := make([]any, len(ids))
|
|
for i, id := range ids {
|
|
ph[i] = fmt.Sprintf("$%d", i+1)
|
|
args[i] = id
|
|
}
|
|
return strings.Join(ph, ","), args
|
|
}
|
|
|
|
// ── User-scoped export ───────────────────────
|
|
|
|
func (s *ExportStore) UserChannels(ctx context.Context, userID string) ([]models.Channel, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, user_id, title, description, type, model, system_prompt,
|
|
provider_config_id, is_archived, is_pinned, folder_id,
|
|
team_id, project_id, workspace_id, settings, allow_anonymous,
|
|
created_at, updated_at
|
|
FROM channels WHERE user_id = $1
|
|
ORDER BY created_at ASC`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: user channels: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Channel
|
|
for rows.Next() {
|
|
var ch models.Channel
|
|
if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description,
|
|
&ch.Type, &ch.Model, &ch.SystemPrompt, &ch.ProviderConfigID,
|
|
&ch.IsArchived, &ch.IsPinned, &ch.FolderID, &ch.TeamID,
|
|
&ch.ProjectID, &ch.WorkspaceID, &ch.Settings, &ch.AllowAnonymous,
|
|
&ch.CreatedAt, &ch.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan channel: %w", err)
|
|
}
|
|
out = append(out, ch)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserMessages(ctx context.Context, channelIDs []string) ([]models.Message, error) {
|
|
if len(channelIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(channelIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id, channel_id, role, content, model, tokens_used,
|
|
tool_calls, metadata, parent_id, sibling_index,
|
|
participant_type, participant_id, provider_config_id,
|
|
created_at, updated_at
|
|
FROM messages
|
|
WHERE channel_id IN (%s) AND deleted_at IS NULL
|
|
ORDER BY created_at ASC`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: user messages: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Message
|
|
for rows.Next() {
|
|
var m models.Message
|
|
if err := rows.Scan(&m.ID, &m.ChannelID, &m.Role, &m.Content,
|
|
&m.Model, &m.TokensUsed, &m.ToolCalls, &m.Metadata,
|
|
&m.ParentID, &m.SiblingIndex, &m.ParticipantType,
|
|
&m.ParticipantID, &m.ProviderConfigID,
|
|
&m.CreatedAt, &m.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan message: %w", err)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserChannelParticipants(ctx context.Context, channelIDs []string) ([]models.ChannelParticipant, error) {
|
|
if len(channelIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(channelIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id, channel_id, participant_type, participant_id, role,
|
|
display_name, avatar_url, joined_at, last_read_at
|
|
FROM channel_participants WHERE channel_id IN (%s)`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: channel participants: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.ChannelParticipant
|
|
for rows.Next() {
|
|
var cp models.ChannelParticipant
|
|
if err := rows.Scan(&cp.ID, &cp.ChannelID, &cp.ParticipantType,
|
|
&cp.ParticipantID, &cp.Role, &cp.DisplayName, &cp.AvatarURL,
|
|
&cp.JoinedAt, &cp.LastReadAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan participant: %w", err)
|
|
}
|
|
out = append(out, cp)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserChannelModels(ctx context.Context, channelIDs []string) ([]models.ChannelModel, error) {
|
|
if len(channelIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(channelIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id, channel_id, model_id, provider_config_id, persona_id,
|
|
handle, display_name, system_prompt, is_default
|
|
FROM channel_models WHERE channel_id IN (%s)`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: channel models: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.ChannelModel
|
|
for rows.Next() {
|
|
var cm models.ChannelModel
|
|
if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID,
|
|
&cm.ProviderConfigID, &cm.PersonaID, &cm.Handle,
|
|
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
|
|
return nil, fmt.Errorf("export: scan channel model: %w", err)
|
|
}
|
|
out = append(out, cm)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserChannelCursors(ctx context.Context, userID string, channelIDs []string) ([]models.ChannelCursor, error) {
|
|
if len(channelIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(channelIDs)
|
|
args = append(args, userID)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id, channel_id, user_id, active_leaf_id, updated_at
|
|
FROM channel_cursors
|
|
WHERE channel_id IN (%s) AND user_id = $%d`, ph, len(args)), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: channel cursors: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.ChannelCursor
|
|
for rows.Next() {
|
|
var cc models.ChannelCursor
|
|
if err := rows.Scan(&cc.ID, &cc.ChannelID, &cc.UserID,
|
|
&cc.ActiveLeafID, &cc.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan cursor: %w", err)
|
|
}
|
|
out = append(out, cc)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserNotes(ctx context.Context, userID string) ([]models.Note, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, user_id, title, content, folder_path, tags,
|
|
metadata, source_channel_id, source_message_id, team_id,
|
|
created_at, updated_at
|
|
FROM notes WHERE user_id = $1
|
|
ORDER BY created_at ASC`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: user notes: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Note
|
|
for rows.Next() {
|
|
var n models.Note
|
|
if err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content,
|
|
&n.FolderPath, pq.Array(&n.Tags), &n.Metadata,
|
|
&n.SourceChannelID, &n.SourceMessageID, &n.TeamID,
|
|
&n.CreatedAt, &n.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan note: %w", err)
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserNoteLinks(ctx context.Context, noteIDs []string) ([]models.ExportNoteLink, error) {
|
|
if len(noteIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(noteIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT source_note_id, target_note_id, target_title,
|
|
COALESCE(display_text, ''), is_transclusion
|
|
FROM note_links WHERE source_note_id IN (%s)`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: note links: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.ExportNoteLink
|
|
for rows.Next() {
|
|
var nl models.ExportNoteLink
|
|
if err := rows.Scan(&nl.SourceNoteID, &nl.TargetNoteID,
|
|
&nl.TargetTitle, &nl.DisplayText, &nl.IsTransclusion); err != nil {
|
|
return nil, fmt.Errorf("export: scan note link: %w", err)
|
|
}
|
|
out = append(out, nl)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserMemories(ctx context.Context, userID string) ([]models.Memory, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, scope, owner_id, user_id, key, value,
|
|
source_channel_id, confidence, status, created_at, updated_at
|
|
FROM memories
|
|
WHERE owner_id = $1 AND scope = 'user' AND status = 'active'
|
|
ORDER BY created_at ASC`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: user memories: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Memory
|
|
for rows.Next() {
|
|
var m models.Memory
|
|
if err := rows.Scan(&m.ID, &m.Scope, &m.OwnerID, &m.UserID,
|
|
&m.Key, &m.Value, &m.SourceChannelID, &m.Confidence,
|
|
&m.Status, &m.CreatedAt, &m.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan memory: %w", err)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserProjects(ctx context.Context, userID string) ([]models.Project, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, name, description, color, icon, scope, owner_id,
|
|
team_id, is_archived, workspace_id, settings, created_at, updated_at
|
|
FROM projects WHERE owner_id = $1 AND scope = 'personal'
|
|
ORDER BY created_at ASC`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: user projects: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Project
|
|
for rows.Next() {
|
|
var p models.Project
|
|
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Color,
|
|
&p.Icon, &p.Scope, &p.OwnerID, &p.TeamID, &p.IsArchived,
|
|
&p.WorkspaceID, &p.Settings, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan project: %w", err)
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserProjectChannels(ctx context.Context, projectIDs []string) ([]models.ProjectChannel, error) {
|
|
if len(projectIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(projectIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT project_id, channel_id, position, folder, added_at
|
|
FROM project_channels WHERE project_id IN (%s)`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: project channels: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.ProjectChannel
|
|
for rows.Next() {
|
|
var pc models.ProjectChannel
|
|
if err := rows.Scan(&pc.ProjectID, &pc.ChannelID, &pc.Position,
|
|
&pc.Folder, &pc.AddedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan project channel: %w", err)
|
|
}
|
|
out = append(out, pc)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserProjectKBs(ctx context.Context, projectIDs []string) ([]models.ProjectKB, error) {
|
|
if len(projectIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(projectIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT project_id, kb_id, auto_search, added_at
|
|
FROM project_knowledge_bases WHERE project_id IN (%s)`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: project KBs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.ProjectKB
|
|
for rows.Next() {
|
|
var pk models.ProjectKB
|
|
if err := rows.Scan(&pk.ProjectID, &pk.KBID, &pk.AutoSearch, &pk.AddedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan project KB: %w", err)
|
|
}
|
|
out = append(out, pk)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserProjectNotes(ctx context.Context, projectIDs []string) ([]models.ProjectNote, error) {
|
|
if len(projectIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(projectIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT project_id, note_id, added_at
|
|
FROM project_notes WHERE project_id IN (%s)`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: project notes: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.ProjectNote
|
|
for rows.Next() {
|
|
var pn models.ProjectNote
|
|
if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan project note: %w", err)
|
|
}
|
|
out = append(out, pn)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserWorkspaces(ctx context.Context, userID string) ([]models.Workspace, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, owner_type, owner_id, name, root_path, max_bytes, status,
|
|
indexing_enabled, git_remote_url, git_branch, git_credential_id,
|
|
git_last_sync, created_at, updated_at
|
|
FROM workspaces WHERE owner_type = 'user' AND owner_id = $1
|
|
ORDER BY created_at ASC`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: user workspaces: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Workspace
|
|
for rows.Next() {
|
|
var w models.Workspace
|
|
if err := rows.Scan(&w.ID, &w.OwnerType, &w.OwnerID, &w.Name,
|
|
&w.RootPath, &w.MaxBytes, &w.Status, &w.IndexingEnabled,
|
|
&w.GitRemoteURL, &w.GitBranch, &w.GitCredentialID,
|
|
&w.GitLastSync, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan workspace: %w", err)
|
|
}
|
|
out = append(out, w)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserWorkspaceFiles(ctx context.Context, workspaceIDs []string) ([]models.WorkspaceFile, error) {
|
|
if len(workspaceIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(workspaceIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id, workspace_id, path, is_directory, content_type,
|
|
size_bytes, sha256, index_status, chunk_count,
|
|
created_at, updated_at
|
|
FROM workspace_files WHERE workspace_id IN (%s)
|
|
ORDER BY path ASC`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: workspace files: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.WorkspaceFile
|
|
for rows.Next() {
|
|
var wf models.WorkspaceFile
|
|
if err := rows.Scan(&wf.ID, &wf.WorkspaceID, &wf.Path,
|
|
&wf.IsDirectory, &wf.ContentType, &wf.SizeBytes,
|
|
&wf.SHA256, &wf.IndexStatus, &wf.ChunkCount,
|
|
&wf.CreatedAt, &wf.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan workspace file: %w", err)
|
|
}
|
|
out = append(out, wf)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserFiles(ctx context.Context, userID string) ([]models.File, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, channel_id, message_id, user_id, project_id,
|
|
origin, filename, content_type, size_bytes, storage_key,
|
|
display_hint, extracted_text, metadata, created_at, updated_at
|
|
FROM files WHERE user_id = $1
|
|
ORDER BY created_at ASC`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: user files: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.File
|
|
for rows.Next() {
|
|
var f models.File
|
|
if err := rows.Scan(&f.ID, &f.ChannelID, &f.MessageID, &f.UserID,
|
|
&f.ProjectID, &f.Origin, &f.Filename, &f.ContentType,
|
|
&f.SizeBytes, &f.StorageKey, &f.DisplayHint,
|
|
&f.ExtractedText, &f.Metadata, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan file: %w", err)
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserFolders(ctx context.Context, userID string) ([]models.Folder, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, user_id, name, parent_id, sort_order, created_at, updated_at
|
|
FROM folders WHERE user_id = $1
|
|
ORDER BY sort_order ASC`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: user folders: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Folder
|
|
for rows.Next() {
|
|
var f models.Folder
|
|
if err := rows.Scan(&f.ID, &f.UserID, &f.Name, &f.ParentID,
|
|
&f.SortOrder, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan folder: %w", err)
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserModelSettings(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, user_id, model_id, provider_config_id, hidden,
|
|
preferred_temperature, preferred_max_tokens, sort_order,
|
|
created_at, updated_at
|
|
FROM user_model_settings WHERE user_id = $1`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: user model settings: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.UserModelSetting
|
|
for rows.Next() {
|
|
var s models.UserModelSetting
|
|
if err := rows.Scan(&s.ID, &s.UserID, &s.ModelID, &s.ProviderConfigID,
|
|
&s.Hidden, &s.PreferredTemperature, &s.PreferredMaxTokens,
|
|
&s.SortOrder, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan user model setting: %w", err)
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserNotifPrefs(ctx context.Context, userID string) ([]models.NotificationPreference, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, user_id, type, in_app, email
|
|
FROM notification_preferences WHERE user_id = $1`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: notification prefs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.NotificationPreference
|
|
for rows.Next() {
|
|
var np models.NotificationPreference
|
|
if err := rows.Scan(&np.ID, &np.UserID, &np.Type, &np.InApp, &np.Email); err != nil {
|
|
return nil, fmt.Errorf("export: scan notif pref: %w", err)
|
|
}
|
|
out = append(out, np)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserUsageEntries(ctx context.Context, userID string) ([]models.UsageEntry, error) {
|
|
cutoff := time.Now().AddDate(0, 0, -90)
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, channel_id, user_id, provider_config_id, provider_scope,
|
|
model_id, role, prompt_tokens, completion_tokens,
|
|
cache_creation_tokens, cache_read_tokens,
|
|
cost_input, cost_output, routing_decision, created_at
|
|
FROM usage_log
|
|
WHERE user_id = $1 AND created_at >= $2
|
|
ORDER BY created_at ASC`, userID, cutoff)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: usage entries: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.UsageEntry
|
|
for rows.Next() {
|
|
var ue models.UsageEntry
|
|
if err := rows.Scan(&ue.ID, &ue.ChannelID, &ue.UserID,
|
|
&ue.ProviderConfigID, &ue.ProviderScope, &ue.ModelID,
|
|
&ue.Role, &ue.PromptTokens, &ue.CompletionTokens,
|
|
&ue.CacheCreationTokens, &ue.CacheReadTokens,
|
|
&ue.CostInput, &ue.CostOutput, &ue.RoutingDecision,
|
|
&ue.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan usage entry: %w", err)
|
|
}
|
|
out = append(out, ue)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserPersonaGroups(ctx context.Context, userID string) ([]models.PersonaGroup, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, name, description, owner_id, scope, team_id,
|
|
created_at, updated_at
|
|
FROM persona_groups WHERE owner_id = $1
|
|
ORDER BY created_at ASC`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: persona groups: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.PersonaGroup
|
|
for rows.Next() {
|
|
var pg models.PersonaGroup
|
|
if err := rows.Scan(&pg.ID, &pg.Name, &pg.Description, &pg.OwnerID,
|
|
&pg.Scope, &pg.TeamID, &pg.CreatedAt, &pg.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan persona group: %w", err)
|
|
}
|
|
out = append(out, pg)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) UserPersonaGroupMembers(ctx context.Context, groupIDs []string) ([]models.PersonaGroupMember, error) {
|
|
if len(groupIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(groupIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id, group_id, persona_id, is_leader, sort_order
|
|
FROM persona_group_members WHERE group_id IN (%s)`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: persona group members: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.PersonaGroupMember
|
|
for rows.Next() {
|
|
var pgm models.PersonaGroupMember
|
|
if err := rows.Scan(&pgm.ID, &pgm.GroupID, &pgm.PersonaID,
|
|
&pgm.IsLeader, &pgm.SortOrder); err != nil {
|
|
return nil, fmt.Errorf("export: scan persona group member: %w", err)
|
|
}
|
|
out = append(out, pgm)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ── Team-scoped export ───────────────────────
|
|
|
|
func (s *ExportStore) TeamChannels(ctx context.Context, teamID string) ([]models.Channel, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, user_id, title, description, type, model, system_prompt,
|
|
provider_config_id, is_archived, is_pinned, folder_id,
|
|
team_id, project_id, workspace_id, settings, allow_anonymous,
|
|
created_at, updated_at
|
|
FROM channels WHERE team_id = $1
|
|
ORDER BY created_at ASC`, teamID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: team channels: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Channel
|
|
for rows.Next() {
|
|
var ch models.Channel
|
|
if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description,
|
|
&ch.Type, &ch.Model, &ch.SystemPrompt, &ch.ProviderConfigID,
|
|
&ch.IsArchived, &ch.IsPinned, &ch.FolderID, &ch.TeamID,
|
|
&ch.ProjectID, &ch.WorkspaceID, &ch.Settings, &ch.AllowAnonymous,
|
|
&ch.CreatedAt, &ch.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan team channel: %w", err)
|
|
}
|
|
out = append(out, ch)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) TeamMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
|
|
u.email, u.display_name, u.username, u.role AS user_role
|
|
FROM team_members tm
|
|
JOIN users u ON u.id = tm.user_id
|
|
WHERE tm.team_id = $1`, teamID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: team members: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.TeamMember
|
|
for rows.Next() {
|
|
var tm models.TeamMember
|
|
if err := rows.Scan(&tm.ID, &tm.TeamID, &tm.UserID, &tm.Role,
|
|
&tm.JoinedAt, &tm.Email, &tm.DisplayName, &tm.Username,
|
|
&tm.UserRole); err != nil {
|
|
return nil, fmt.Errorf("export: scan team member: %w", err)
|
|
}
|
|
out = append(out, tm)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) TeamPersonas(ctx context.Context, teamID string) ([]models.Persona, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, name, handle, description, icon, avatar,
|
|
base_model_id, provider_config_id,
|
|
system_prompt, temperature, max_tokens, thinking_budget, top_p,
|
|
scope, owner_id, created_by, is_active, is_shared,
|
|
memory_enabled, memory_extraction_prompt,
|
|
created_at, updated_at
|
|
FROM personas WHERE scope = 'team' AND owner_id = $1
|
|
ORDER BY created_at ASC`, teamID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: team personas: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Persona
|
|
for rows.Next() {
|
|
var p models.Persona
|
|
if err := rows.Scan(&p.ID, &p.Name, &p.Handle, &p.Description,
|
|
&p.Icon, &p.Avatar, &p.BaseModelID, &p.ProviderConfigID,
|
|
&p.SystemPrompt, &p.Temperature, &p.MaxTokens,
|
|
&p.ThinkingBudget, &p.TopP, &p.Scope, &p.OwnerID,
|
|
&p.CreatedBy, &p.IsActive, &p.IsShared,
|
|
&p.MemoryEnabled, &p.MemoryExtractionPrompt,
|
|
&p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan persona: %w", err)
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) TeamPersonaKBs(ctx context.Context, personaIDs []string) ([]models.PersonaKB, error) {
|
|
if len(personaIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(personaIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT persona_id, kb_id, auto_search, added_at
|
|
FROM persona_knowledge_bases WHERE persona_id IN (%s)`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: persona KBs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.PersonaKB
|
|
for rows.Next() {
|
|
var pk models.PersonaKB
|
|
if err := rows.Scan(&pk.PersonaID, &pk.KBID, &pk.AutoSearch, &pk.AddedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan persona KB: %w", err)
|
|
}
|
|
out = append(out, pk)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) TeamKnowledgeBases(ctx context.Context, teamID string) ([]models.KnowledgeBase, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, name, description, scope, owner_id, team_id,
|
|
embedding_config, document_count, chunk_count, total_bytes,
|
|
discoverable, status, created_at, updated_at
|
|
FROM knowledge_bases WHERE team_id = $1
|
|
ORDER BY created_at ASC`, teamID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: team KBs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.KnowledgeBase
|
|
for rows.Next() {
|
|
var kb models.KnowledgeBase
|
|
if err := rows.Scan(&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
|
|
&kb.OwnerID, &kb.TeamID, &kb.EmbeddingConfig, &kb.DocumentCount,
|
|
&kb.ChunkCount, &kb.TotalBytes, &kb.Discoverable, &kb.Status,
|
|
&kb.CreatedAt, &kb.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan KB: %w", err)
|
|
}
|
|
out = append(out, kb)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) TeamKBDocuments(ctx context.Context, kbIDs []string) ([]models.KBDocument, error) {
|
|
if len(kbIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(kbIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id, kb_id, filename, content_type, size_bytes, storage_key,
|
|
chunk_count, status, error, uploaded_by, created_at, updated_at
|
|
FROM kb_documents WHERE kb_id IN (%s)
|
|
ORDER BY created_at ASC`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: KB documents: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.KBDocument
|
|
for rows.Next() {
|
|
var d models.KBDocument
|
|
if err := rows.Scan(&d.ID, &d.KBID, &d.Filename, &d.ContentType,
|
|
&d.SizeBytes, &d.StorageKey, &d.ChunkCount, &d.Status,
|
|
&d.Error, &d.UploadedBy, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan KB document: %w", err)
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) TeamWorkflows(ctx context.Context, teamID string) ([]models.Workflow, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, team_id, name, slug, description, branding,
|
|
entry_mode, is_active, version, on_complete, retention,
|
|
webhook_url, webhook_secret, created_by, created_at, updated_at
|
|
FROM workflows WHERE team_id = $1
|
|
ORDER BY created_at ASC`, teamID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: team workflows: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Workflow
|
|
for rows.Next() {
|
|
var w models.Workflow
|
|
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug,
|
|
&w.Description, &w.Branding, &w.EntryMode, &w.IsActive,
|
|
&w.Version, &w.OnComplete, &w.Retention, &w.WebhookURL,
|
|
&w.WebhookSecret, &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan workflow: %w", err)
|
|
}
|
|
out = append(out, w)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) TeamWorkflowVersions(ctx context.Context, workflowIDs []string) ([]models.WorkflowVersion, error) {
|
|
if len(workflowIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(workflowIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id, workflow_id, version_number, snapshot, created_at
|
|
FROM workflow_versions WHERE workflow_id IN (%s)
|
|
ORDER BY version_number ASC`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: workflow versions: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.WorkflowVersion
|
|
for rows.Next() {
|
|
var wv models.WorkflowVersion
|
|
if err := rows.Scan(&wv.ID, &wv.WorkflowID, &wv.VersionNumber,
|
|
&wv.Snapshot, &wv.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan workflow version: %w", err)
|
|
}
|
|
out = append(out, wv)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) TeamWorkflowStages(ctx context.Context, workflowIDs []string) ([]models.WorkflowStage, error) {
|
|
if len(workflowIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(workflowIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id, workflow_id, ordinal, name, persona_id,
|
|
assignment_team_id, form_template, stage_mode,
|
|
history_mode, auto_transition, transition_rules,
|
|
surface_pkg_id, created_at
|
|
FROM workflow_stages WHERE workflow_id IN (%s)
|
|
ORDER BY ordinal ASC`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: workflow stages: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.WorkflowStage
|
|
for rows.Next() {
|
|
var ws models.WorkflowStage
|
|
if err := rows.Scan(&ws.ID, &ws.WorkflowID, &ws.Ordinal, &ws.Name,
|
|
&ws.PersonaID, &ws.AssignmentTeamID, &ws.FormTemplate,
|
|
&ws.StageMode, &ws.HistoryMode, &ws.AutoTransition,
|
|
&ws.TransitionRules, &ws.SurfacePkgID, &ws.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan workflow stage: %w", err)
|
|
}
|
|
out = append(out, ws)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) TeamGroups(ctx context.Context, teamID string) ([]models.Group, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, name, description, scope, team_id, created_by, source,
|
|
permissions, token_budget_daily, token_budget_monthly,
|
|
allowed_models, created_at, updated_at
|
|
FROM groups WHERE team_id = $1
|
|
ORDER BY created_at ASC`, teamID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: team groups: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Group
|
|
for rows.Next() {
|
|
var g models.Group
|
|
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope,
|
|
&g.TeamID, &g.CreatedBy, &g.Source,
|
|
pq.Array(&g.Permissions), &g.TokenBudgetDaily,
|
|
&g.TokenBudgetMonthly, pq.Array(&g.AllowedModels),
|
|
&g.CreatedAt, &g.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan group: %w", err)
|
|
}
|
|
out = append(out, g)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) TeamGroupMembers(ctx context.Context, groupIDs []string) ([]models.GroupMember, error) {
|
|
if len(groupIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph, args := placeholders(groupIDs)
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id, group_id, user_id, added_by, added_at
|
|
FROM group_members WHERE group_id IN (%s)`, ph), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: group members: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.GroupMember
|
|
for rows.Next() {
|
|
var gm models.GroupMember
|
|
if err := rows.Scan(&gm.ID, &gm.GroupID, &gm.UserID, &gm.AddedBy,
|
|
&gm.AddedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan group member: %w", err)
|
|
}
|
|
out = append(out, gm)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) TeamResourceGrants(ctx context.Context, teamID string) ([]models.ResourceGrant, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT rg.id, rg.resource_type, rg.resource_id, rg.grant_scope,
|
|
rg.granted_groups, rg.created_by, rg.created_at, rg.updated_at
|
|
FROM resource_grants rg
|
|
WHERE rg.resource_id IN (
|
|
SELECT id FROM personas WHERE scope = 'team' AND owner_id = $1
|
|
UNION ALL
|
|
SELECT id FROM knowledge_bases WHERE team_id = $1
|
|
)`, teamID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: team resource grants: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.ResourceGrant
|
|
for rows.Next() {
|
|
var rg models.ResourceGrant
|
|
if err := rows.Scan(&rg.ID, &rg.ResourceType, &rg.ResourceID,
|
|
&rg.GrantScope, pq.Array(&rg.GrantedGroups),
|
|
&rg.CreatedBy, &rg.CreatedAt, &rg.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan resource grant: %w", err)
|
|
}
|
|
out = append(out, rg)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ExportStore) TeamProjects(ctx context.Context, teamID string) ([]models.Project, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, name, description, color, icon, scope, owner_id,
|
|
team_id, is_archived, workspace_id, settings, created_at, updated_at
|
|
FROM projects WHERE team_id = $1
|
|
ORDER BY created_at ASC`, teamID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: team projects: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Project
|
|
for rows.Next() {
|
|
var p models.Project
|
|
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Color,
|
|
&p.Icon, &p.Scope, &p.OwnerID, &p.TeamID, &p.IsArchived,
|
|
&p.WorkspaceID, &p.Settings, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("export: scan project: %w", err)
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ── GDPR ─────────────────────────────────────
|
|
|
|
func (s *ExportStore) CountActiveAdmins(ctx context.Context) (int, error) {
|
|
var count int
|
|
err := DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND is_active = true`).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
func (s *ExportStore) AnonymizeUser(ctx context.Context, userID string, anonHash string) error {
|
|
_, err := DB.ExecContext(ctx, `
|
|
UPDATE users SET
|
|
username = $2,
|
|
email = $3,
|
|
display_name = 'Deleted User',
|
|
avatar_url = '',
|
|
handle = $4,
|
|
settings = NULL,
|
|
external_id = NULL,
|
|
is_active = false,
|
|
password_hash = '',
|
|
updated_at = NOW()
|
|
WHERE id = $1`,
|
|
userID,
|
|
"deleted-user-"+anonHash,
|
|
"deleted-"+anonHash+"@deleted.local",
|
|
"deleted-"+anonHash,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *ExportStore) SoftDeleteUserData(ctx context.Context, userID string) (map[string]int64, error) {
|
|
counts := make(map[string]int64)
|
|
tx, err := DB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
now := time.Now()
|
|
|
|
// Messages in user-owned channels
|
|
r, err := tx.ExecContext(ctx, `
|
|
UPDATE messages SET deleted_at = $1
|
|
WHERE channel_id IN (SELECT id FROM channels WHERE user_id = $2)
|
|
AND deleted_at IS NULL`, now, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: soft-delete owned messages: %w", err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
counts["messages_owned"] = n
|
|
|
|
// Messages sent by user in other channels
|
|
r, err = tx.ExecContext(ctx, `
|
|
UPDATE messages SET deleted_at = $1
|
|
WHERE participant_type = 'user' AND participant_id = $2
|
|
AND deleted_at IS NULL`, now, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: soft-delete sent messages: %w", err)
|
|
}
|
|
n, _ = r.RowsAffected()
|
|
counts["messages_sent"] = n
|
|
|
|
// Archive channels
|
|
r, err = tx.ExecContext(ctx, `UPDATE channels SET is_archived = true WHERE user_id = $1`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: archive channels: %w", err)
|
|
}
|
|
n, _ = r.RowsAffected()
|
|
counts["channels"] = n
|
|
|
|
// Notes + links
|
|
tx.ExecContext(ctx, `DELETE FROM note_links WHERE source_note_id IN (SELECT id FROM notes WHERE user_id = $1)`, userID)
|
|
r, err = tx.ExecContext(ctx, `DELETE FROM notes WHERE user_id = $1`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: delete notes: %w", err)
|
|
}
|
|
n, _ = r.RowsAffected()
|
|
counts["notes"] = n
|
|
|
|
// Memories
|
|
r, err = tx.ExecContext(ctx, `DELETE FROM memories WHERE owner_id = $1 AND scope = 'user'`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: delete memories: %w", err)
|
|
}
|
|
n, _ = r.RowsAffected()
|
|
counts["memories"] = n
|
|
|
|
// Workspace cascade
|
|
tx.ExecContext(ctx, `DELETE FROM workspace_chunks WHERE workspace_id IN (SELECT id FROM workspaces WHERE owner_type = 'user' AND owner_id = $1)`, userID)
|
|
tx.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id IN (SELECT id FROM workspaces WHERE owner_type = 'user' AND owner_id = $1)`, userID)
|
|
r, err = tx.ExecContext(ctx, `DELETE FROM workspaces WHERE owner_type = 'user' AND owner_id = $1`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: delete workspaces: %w", err)
|
|
}
|
|
n, _ = r.RowsAffected()
|
|
counts["workspaces"] = n
|
|
|
|
// Files
|
|
r, err = tx.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: delete files: %w", err)
|
|
}
|
|
n, _ = r.RowsAffected()
|
|
counts["files"] = n
|
|
|
|
// Projects cascade
|
|
tx.ExecContext(ctx, `DELETE FROM project_notes WHERE project_id IN (SELECT id FROM projects WHERE scope = 'personal' AND owner_id = $1)`, userID)
|
|
tx.ExecContext(ctx, `DELETE FROM project_knowledge_bases WHERE project_id IN (SELECT id FROM projects WHERE scope = 'personal' AND owner_id = $1)`, userID)
|
|
tx.ExecContext(ctx, `DELETE FROM project_channels WHERE project_id IN (SELECT id FROM projects WHERE scope = 'personal' AND owner_id = $1)`, userID)
|
|
r, err = tx.ExecContext(ctx, `DELETE FROM projects WHERE scope = 'personal' AND owner_id = $1`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("export: delete projects: %w", err)
|
|
}
|
|
n, _ = r.RowsAffected()
|
|
counts["projects"] = n
|
|
|
|
// Folders, settings, prefs, notifications
|
|
tx.ExecContext(ctx, `DELETE FROM folders WHERE user_id = $1`, userID)
|
|
tx.ExecContext(ctx, `DELETE FROM user_model_settings WHERE user_id = $1`, userID)
|
|
tx.ExecContext(ctx, `DELETE FROM notification_preferences WHERE user_id = $1`, userID)
|
|
tx.ExecContext(ctx, `DELETE FROM notifications WHERE user_id = $1`, userID)
|
|
|
|
// Persona groups
|
|
tx.ExecContext(ctx, `DELETE FROM persona_group_members WHERE group_id IN (SELECT id FROM persona_groups WHERE owner_id = $1)`, userID)
|
|
tx.ExecContext(ctx, `DELETE FROM persona_groups WHERE owner_id = $1`, userID)
|
|
|
|
// Channel participation
|
|
tx.ExecContext(ctx, `DELETE FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1`, userID)
|
|
tx.ExecContext(ctx, `DELETE FROM channel_cursors WHERE user_id = $1`, userID)
|
|
|
|
// Memory extraction log, presence
|
|
tx.ExecContext(ctx, `DELETE FROM memory_extraction_log WHERE user_id = $1`, userID)
|
|
tx.ExecContext(ctx, `DELETE FROM user_presence WHERE user_id = $1`, userID)
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("export: commit: %w", err)
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
func (s *ExportStore) DeleteUserTokens(ctx context.Context, userID string) error {
|
|
_, err := DB.ExecContext(ctx, `DELETE FROM refresh_tokens WHERE user_id = $1`, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("export: delete refresh tokens: %w", err)
|
|
}
|
|
_, err = DB.ExecContext(ctx, `DELETE FROM ws_tickets WHERE user_id = $1`, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("export: delete ws tickets: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Import (CS1) ─────────────────────────────
|
|
|
|
func (s *ExportStore) ImportFolders(ctx context.Context, folders []models.Folder) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, f := range folders {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO folders (id, user_id, name, parent_id, sort_order, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
f.ID, f.UserID, f.Name, f.ParentID, f.SortOrder, f.CreatedAt, f.UpdatedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import folder %s: %w", f.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportProjects(ctx context.Context, projects []models.Project) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, p := range projects {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO projects (id, name, description, color, icon, scope, owner_id,
|
|
team_id, is_archived, workspace_id, settings, created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
p.ID, p.Name, p.Description, p.Color, p.Icon, p.Scope, p.OwnerID,
|
|
p.TeamID, p.IsArchived, p.WorkspaceID, p.Settings, p.CreatedAt, p.UpdatedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import project %s: %w", p.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportWorkspaces(ctx context.Context, workspaces []models.Workspace) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, w := range workspaces {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status,
|
|
indexing_enabled, git_remote_url, git_branch, git_credential_id,
|
|
git_last_sync, created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath, w.MaxBytes, w.Status,
|
|
w.IndexingEnabled, w.GitRemoteURL, w.GitBranch, w.GitCredentialID,
|
|
w.GitLastSync, w.CreatedAt, w.UpdatedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import workspace %s: %w", w.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportChannels(ctx context.Context, channels []models.Channel) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, ch := range channels {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO channels (id, user_id, title, description, type, model, system_prompt,
|
|
provider_config_id, is_archived, is_pinned, folder_id,
|
|
team_id, project_id, workspace_id, settings, allow_anonymous,
|
|
created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
ch.ID, ch.UserID, ch.Title, ch.Description, ch.Type, ch.Model, ch.SystemPrompt,
|
|
ch.ProviderConfigID, ch.IsArchived, ch.IsPinned, ch.FolderID, ch.TeamID,
|
|
ch.ProjectID, ch.WorkspaceID, ch.Settings, ch.AllowAnonymous,
|
|
ch.CreatedAt, ch.UpdatedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import channel %s: %w", ch.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportChannelParticipants(ctx context.Context, cps []models.ChannelParticipant) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, cp := range cps {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role,
|
|
display_name, avatar_url, joined_at, last_read_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
cp.ID, cp.ChannelID, cp.ParticipantType, cp.ParticipantID, cp.Role,
|
|
cp.DisplayName, cp.AvatarURL, cp.JoinedAt, cp.LastReadAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import participant %s: %w", cp.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportChannelModels(ctx context.Context, cms []models.ChannelModel) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, cm := range cms {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, persona_id,
|
|
handle, display_name, system_prompt, is_default)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
cm.ID, cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.PersonaID,
|
|
cm.Handle, cm.DisplayName, cm.SystemPrompt, cm.IsDefault)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import channel model %s: %w", cm.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportChannelCursors(ctx context.Context, cursors []models.ChannelCursor) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, cc := range cursors {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO channel_cursors (id, channel_id, user_id, active_leaf_id, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
cc.ID, cc.ChannelID, cc.UserID, cc.ActiveLeafID, cc.UpdatedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import cursor %s: %w", cc.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportMessages(ctx context.Context, msgs []models.Message) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, m := range msgs {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO messages (id, channel_id, role, content, model, tokens_used,
|
|
tool_calls, metadata, parent_id, sibling_index,
|
|
participant_type, participant_id, provider_config_id,
|
|
created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
m.ID, m.ChannelID, m.Role, m.Content, m.Model, m.TokensUsed,
|
|
m.ToolCalls, m.Metadata, m.ParentID, m.SiblingIndex,
|
|
m.ParticipantType, m.ParticipantID, m.ProviderConfigID,
|
|
m.CreatedAt, m.UpdatedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import message %s: %w", m.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportNotes(ctx context.Context, notes []models.Note) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, n := range notes {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO notes (id, user_id, title, content, folder_path, tags,
|
|
metadata, source_channel_id, source_message_id, team_id,
|
|
created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
n.ID, n.UserID, n.Title, n.Content, n.FolderPath, pq.Array(n.Tags),
|
|
n.Metadata, n.SourceChannelID, n.SourceMessageID, n.TeamID,
|
|
n.CreatedAt, n.UpdatedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import note %s: %w", n.ID, err)
|
|
}
|
|
nr, _ := r.RowsAffected()
|
|
if nr > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportNoteLinks(ctx context.Context, links []models.ExportNoteLink) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, nl := range links {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO note_links (source_note_id, target_note_id, target_title, display_text, is_transclusion)
|
|
VALUES ($1,$2,$3,$4,$5)
|
|
ON CONFLICT DO NOTHING`,
|
|
nl.SourceNoteID, nl.TargetNoteID, nl.TargetTitle, nl.DisplayText, nl.IsTransclusion)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import note link: %w", err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportMemories(ctx context.Context, mems []models.Memory) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, m := range mems {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO memories (id, scope, owner_id, user_id, key, value,
|
|
source_channel_id, confidence, status, created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
m.ID, m.Scope, m.OwnerID, m.UserID, m.Key, m.Value,
|
|
m.SourceChannelID, m.Confidence, m.Status, m.CreatedAt, m.UpdatedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import memory %s: %w", m.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportProjectChannels(ctx context.Context, pcs []models.ProjectChannel) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, pc := range pcs {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO project_channels (project_id, channel_id, position, folder, added_at)
|
|
VALUES ($1,$2,$3,$4,$5)
|
|
ON CONFLICT DO NOTHING`,
|
|
pc.ProjectID, pc.ChannelID, pc.Position, pc.Folder, pc.AddedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import project channel: %w", err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportProjectKBs(ctx context.Context, pks []models.ProjectKB) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, pk := range pks {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO project_knowledge_bases (project_id, kb_id, auto_search, added_at)
|
|
VALUES ($1,$2,$3,$4)
|
|
ON CONFLICT DO NOTHING`,
|
|
pk.ProjectID, pk.KBID, pk.AutoSearch, pk.AddedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import project KB: %w", err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportProjectNotes(ctx context.Context, pns []models.ProjectNote) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, pn := range pns {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO project_notes (project_id, note_id, added_at)
|
|
VALUES ($1,$2,$3)
|
|
ON CONFLICT DO NOTHING`,
|
|
pn.ProjectID, pn.NoteID, pn.AddedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import project note: %w", err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportWorkspaceFiles(ctx context.Context, wfs []models.WorkspaceFile) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, wf := range wfs {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO workspace_files (id, workspace_id, path, is_directory, content_type,
|
|
size_bytes, sha256, index_status, chunk_count, created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
wf.ID, wf.WorkspaceID, wf.Path, wf.IsDirectory, wf.ContentType,
|
|
wf.SizeBytes, wf.SHA256, wf.IndexStatus, wf.ChunkCount,
|
|
wf.CreatedAt, wf.UpdatedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import workspace file %s: %w", wf.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportFiles(ctx context.Context, files []models.File) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, f := range files {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO files (id, channel_id, message_id, user_id, project_id,
|
|
origin, filename, content_type, size_bytes, storage_key,
|
|
display_hint, extracted_text, metadata, created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
f.ID, f.ChannelID, f.MessageID, f.UserID, f.ProjectID,
|
|
f.Origin, f.Filename, f.ContentType, f.SizeBytes, f.StorageKey,
|
|
f.DisplayHint, f.ExtractedText, f.Metadata, f.CreatedAt, f.UpdatedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import file %s: %w", f.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportUserModelSettings(ctx context.Context, ums []models.UserModelSetting) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, um := range ums {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO user_model_settings (id, user_id, model_id, provider_config_id, hidden,
|
|
preferred_temperature, preferred_max_tokens, sort_order, created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
um.ID, um.UserID, um.ModelID, um.ProviderConfigID, um.Hidden,
|
|
um.PreferredTemperature, um.PreferredMaxTokens, um.SortOrder,
|
|
um.CreatedAt, um.UpdatedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import user model setting %s: %w", um.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportNotifPrefs(ctx context.Context, nps []models.NotificationPreference) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, np := range nps {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO notification_preferences (id, user_id, type, in_app, email)
|
|
VALUES ($1,$2,$3,$4,$5)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
np.ID, np.UserID, np.Type, np.InApp, np.Email)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import notif pref %s: %w", np.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportPersonaGroups(ctx context.Context, pgs []models.PersonaGroup) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, pg := range pgs {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO persona_groups (id, name, description, owner_id, scope, team_id,
|
|
created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
pg.ID, pg.Name, pg.Description, pg.OwnerID, pg.Scope, pg.TeamID,
|
|
pg.CreatedAt, pg.UpdatedAt)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import persona group %s: %w", pg.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|
|
|
|
func (s *ExportStore) ImportPersonaGroupMembers(ctx context.Context, pgms []models.PersonaGroupMember) (int, int, error) {
|
|
imported, skipped := 0, 0
|
|
for _, pgm := range pgms {
|
|
r, err := DB.ExecContext(ctx, `
|
|
INSERT INTO persona_group_members (id, group_id, persona_id, is_leader, sort_order)
|
|
VALUES ($1,$2,$3,$4,$5)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
pgm.ID, pgm.GroupID, pgm.PersonaID, pgm.IsLeader, pgm.SortOrder)
|
|
if err != nil {
|
|
return imported, skipped, fmt.Errorf("import persona group member %s: %w", pgm.ID, err)
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n > 0 { imported++ } else { skipped++ }
|
|
}
|
|
return imported, skipped, nil
|
|
}
|