package store import ( "context" "encoding/json" "fmt" "time" "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/user/models" ) const ( DefaultUserID = "default-user" DefaultUserEmail = "default@kandev.local" ) type sqliteRepository struct { db *sqlx.DB // writer ro *sqlx.DB // reader ownsDB bool } var _ Repository = (*sqliteRepository)(nil) func newSQLiteRepositoryWithDB(writer, reader *sqlx.DB) (*sqliteRepository, error) { return newSQLiteRepository(writer, reader, false) } func newSQLiteRepository(writer, reader *sqlx.DB, ownsDB bool) (*sqliteRepository, error) { repo := &sqliteRepository{db: writer, ro: reader, ownsDB: ownsDB} if err := repo.initSchema(); err != nil { if ownsDB { if closeErr := writer.Close(); closeErr != nil { return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr) } } return nil, fmt.Errorf("failed to initialize schema: %w", err) } return repo, nil } func (r *sqliteRepository) initSchema() error { schema := ` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL, settings TEXT NOT NULL DEFAULT '{}', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); ` if _, err := r.db.Exec(schema); err != nil { return err } return r.ensureDefaultUser() } func (r *sqliteRepository) ensureDefaultUser() error { ctx := context.Background() var count int if err := r.db.QueryRowContext(ctx, r.db.Rebind("SELECT COUNT(1) FROM users WHERE id = ?"), DefaultUserID).Scan(&count); err != nil { return err } if count == 0 { now := time.Now().UTC() _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO users (id, email, settings, created_at, updated_at) VALUES (?, ?, '{}', ?, ?) `), DefaultUserID, DefaultUserEmail, now, now) if err != nil { return err } } return nil } func (r *sqliteRepository) Close() error { if !r.ownsDB { return nil } return r.db.Close() } func (r *sqliteRepository) GetUser(ctx context.Context, id string) (*models.User, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT id, email, created_at, updated_at FROM users WHERE id = ? `), id) return scanUser(row) } func (r *sqliteRepository) GetDefaultUser(ctx context.Context) (*models.User, error) { return r.GetUser(ctx, DefaultUserID) } func (r *sqliteRepository) GetUserSettings(ctx context.Context, userID string) (*models.UserSettings, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT settings, updated_at FROM users WHERE id = ? `), userID) settings, err := scanUserSettings(row, userID) if err != nil { return nil, err } return settings, nil } func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *models.UserSettings) error { settings.UpdatedAt = time.Now().UTC() if settings.CreatedAt.IsZero() { settings.CreatedAt = settings.UpdatedAt } lspAutoStart := settings.LspAutoStartLanguages if lspAutoStart == nil { lspAutoStart = []string{} } lspAutoInstall := settings.LspAutoInstallLanguages if lspAutoInstall == nil { lspAutoInstall = []string{} } lspServerConfigs := settings.LspServerConfigs if lspServerConfigs == nil { lspServerConfigs = map[string]map[string]interface{}{} } savedLayouts := settings.SavedLayouts if savedLayouts == nil { savedLayouts = []models.SavedLayout{} } sidebarViews := settings.SidebarViews if sidebarViews == nil { sidebarViews = []models.SidebarView{} } sidebarTaskPrefs := normalizeSidebarTaskPrefs(settings.SidebarTaskPrefs) keyboardShortcuts := settings.KeyboardShortcuts if keyboardShortcuts == nil { keyboardShortcuts = map[string]interface{}{} } settingsPayload, err := json.Marshal(map[string]interface{}{ "workspace_id": settings.WorkspaceID, "kanban_view_mode": settings.KanbanViewMode, "workflow_filter_id": settings.WorkflowFilterID, "repository_ids": settings.RepositoryIDs, "initial_setup_complete": settings.InitialSetupComplete, "preferred_shell": settings.PreferredShell, "default_editor_id": settings.DefaultEditorID, "enable_preview_on_click": settings.EnablePreviewOnClick, "chat_submit_key": settings.ChatSubmitKey, "review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll, "show_release_notification": settings.ShowReleaseNotification, "release_notes_last_seen_version": settings.ReleaseNotesLastSeenVersion, "lsp_auto_start_languages": lspAutoStart, "lsp_auto_install_languages": lspAutoInstall, "lsp_server_configs": lspServerConfigs, "saved_layouts": savedLayouts, "sidebar_views": sidebarViews, "sidebar_active_view_id": settings.SidebarActiveViewID, "sidebar_draft": settings.SidebarDraft, "sidebar_task_prefs": sidebarTaskPrefs, "task_create_last_used": settings.TaskCreateLastUsed, "jira_saved_views": settings.JiraSavedViews, "jira_task_presets": settings.JiraTaskPresets, "github_saved_presets": settings.GitHubSavedPresets, "github_default_query_presets": settings.GitHubDefaultQueryPresets, "gitlab_saved_presets": settings.GitLabSavedPresets, "default_utility_agent_id": settings.DefaultUtilityAgentID, "default_utility_model": settings.DefaultUtilityModel, "keyboard_shortcuts": keyboardShortcuts, "terminal_link_behavior": settings.TerminalLinkBehavior, "terminal_font_family": settings.TerminalFontFamily, "terminal_font_size": settings.TerminalFontSize, "changes_panel_layout": settings.ChangesPanelLayout, "system_metrics_display": settings.SystemMetricsDisplay, "voice_mode": settings.VoiceMode, }) if err != nil { return err } result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE users SET settings = ?, updated_at = ? WHERE id = ? `), string(settingsPayload), settings.UpdatedAt, settings.UserID) if err != nil { return err } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to check rows affected: %w", err) } if rows == 0 { return fmt.Errorf("user not found: %s", settings.UserID) } return nil } func scanUser(scanner interface{ Scan(dest ...any) error }) (*models.User, error) { user := &models.User{} if err := scanner.Scan(&user.ID, &user.Email, &user.CreatedAt, &user.UpdatedAt); err != nil { return nil, err } return user, nil } // defaultVoiceModeSettings returns the baseline VoiceMode configuration for // users with no saved preferences. Mirrored on the frontend; keep in sync. func defaultVoiceModeSettings() models.VoiceModeSettings { return models.VoiceModeSettings{ Enabled: true, Engine: "auto", Language: "auto", Mode: "toggle", AutoSend: false, WhisperWebModel: "base", } } // storedVoiceMode is the on-disk JSON shape — uses *bool for `enabled` so we // can distinguish "absent" (older rows written before the toggle existed — // must default to true) from "explicitly false" (user disabled the feature). type storedVoiceMode struct { Enabled *bool `json:"enabled"` Engine string `json:"engine"` Language string `json:"language"` Mode string `json:"mode"` AutoSend bool `json:"auto_send"` WhisperWebModel string `json:"whisper_web_model"` } // mergeVoiceModeDefaults fills in zero/missing fields on a stored VoiceMode // payload so older user rows (written before VoiceMode existed) still produce // usable settings instead of empty strings the frontend would reject. func mergeVoiceModeDefaults(stored *storedVoiceMode) models.VoiceModeSettings { out := defaultVoiceModeSettings() if stored == nil { return out } if stored.Enabled != nil { out.Enabled = *stored.Enabled } if stored.Engine != "" { out.Engine = stored.Engine } if stored.Language != "" { out.Language = stored.Language } if stored.Mode != "" { out.Mode = stored.Mode } if stored.WhisperWebModel != "" { out.WhisperWebModel = stored.WhisperWebModel } out.AutoSend = stored.AutoSend return out } func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID string) (*models.UserSettings, error) { settings := &models.UserSettings{} var settingsRaw string if err := scanner.Scan(&settingsRaw, &settings.UpdatedAt); err != nil { return nil, err } settings.UserID = userID if settingsRaw == "" || settingsRaw == "{}" { settings.RepositoryIDs = []string{} settings.ShowReleaseNotification = true settings.ReviewAutoMarkOnScroll = true settings.ChatSubmitKey = "cmd_enter" settings.KeyboardShortcuts = map[string]interface{}{} settings.TerminalLinkBehavior = "new_tab" settings.ChangesPanelLayout = "tree" settings.SidebarViews = []models.SidebarView{} settings.SidebarTaskPrefs = normalizeSidebarTaskPrefs(models.SidebarTaskPrefs{}) settings.VoiceMode = defaultVoiceModeSettings() return settings, nil } var payload struct { WorkspaceID string `json:"workspace_id"` KanbanViewMode string `json:"kanban_view_mode"` WorkflowFilterID string `json:"workflow_filter_id"` RepositoryIDs []string `json:"repository_ids"` InitialSetupComplete bool `json:"initial_setup_complete"` PreferredShell string `json:"preferred_shell"` DefaultEditorID string `json:"default_editor_id"` EnablePreviewOnClick bool `json:"enable_preview_on_click"` ChatSubmitKey string `json:"chat_submit_key"` ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll"` ShowReleaseNotification *bool `json:"show_release_notification"` ReleaseNotesLastSeenVersion string `json:"release_notes_last_seen_version"` LspAutoStartLanguages []string `json:"lsp_auto_start_languages"` LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"` LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"` SavedLayouts []models.SavedLayout `json:"saved_layouts"` SidebarViews []models.SidebarView `json:"sidebar_views"` SidebarActiveViewID string `json:"sidebar_active_view_id"` SidebarDraft *models.SidebarViewDraft `json:"sidebar_draft"` SidebarTaskPrefs models.SidebarTaskPrefs `json:"sidebar_task_prefs"` TaskCreateLastUsed models.TaskCreateLastUsed `json:"task_create_last_used"` JiraSavedViews json.RawMessage `json:"jira_saved_views"` JiraTaskPresets json.RawMessage `json:"jira_task_presets"` GitHubSavedPresets json.RawMessage `json:"github_saved_presets"` GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets"` GitLabSavedPresets json.RawMessage `json:"gitlab_saved_presets"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` DefaultUtilityModel string `json:"default_utility_model"` KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` TerminalLinkBehavior string `json:"terminal_link_behavior"` TerminalFontFamily string `json:"terminal_font_family"` TerminalFontSize int `json:"terminal_font_size"` ChangesPanelLayout string `json:"changes_panel_layout"` SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` VoiceMode *storedVoiceMode `json:"voice_mode"` } if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil { return nil, err } settings.WorkspaceID = payload.WorkspaceID settings.KanbanViewMode = payload.KanbanViewMode settings.WorkflowFilterID = payload.WorkflowFilterID settings.RepositoryIDs = payload.RepositoryIDs settings.InitialSetupComplete = payload.InitialSetupComplete settings.PreferredShell = payload.PreferredShell settings.DefaultEditorID = payload.DefaultEditorID settings.EnablePreviewOnClick = payload.EnablePreviewOnClick settings.ChatSubmitKey = payload.ChatSubmitKey // Default to "cmd_enter" if empty if settings.ChatSubmitKey == "" { settings.ChatSubmitKey = "cmd_enter" } // Default to true when not set if payload.ReviewAutoMarkOnScroll != nil { settings.ReviewAutoMarkOnScroll = *payload.ReviewAutoMarkOnScroll } else { settings.ReviewAutoMarkOnScroll = true } if payload.ShowReleaseNotification != nil { settings.ShowReleaseNotification = *payload.ShowReleaseNotification } else { settings.ShowReleaseNotification = true } settings.ReleaseNotesLastSeenVersion = payload.ReleaseNotesLastSeenVersion settings.LspAutoStartLanguages = payload.LspAutoStartLanguages if settings.LspAutoStartLanguages == nil { settings.LspAutoStartLanguages = []string{} } settings.LspAutoInstallLanguages = payload.LspAutoInstallLanguages if settings.LspAutoInstallLanguages == nil { settings.LspAutoInstallLanguages = []string{} } settings.LspServerConfigs = payload.LspServerConfigs settings.SavedLayouts = payload.SavedLayouts if settings.SavedLayouts == nil { settings.SavedLayouts = []models.SavedLayout{} } settings.SidebarViews = payload.SidebarViews if settings.SidebarViews == nil { settings.SidebarViews = []models.SidebarView{} } settings.SidebarActiveViewID = payload.SidebarActiveViewID settings.SidebarDraft = payload.SidebarDraft settings.SidebarTaskPrefs = normalizeSidebarTaskPrefs(payload.SidebarTaskPrefs) settings.TaskCreateLastUsed = payload.TaskCreateLastUsed settings.JiraSavedViews = payload.JiraSavedViews settings.JiraTaskPresets = payload.JiraTaskPresets settings.GitHubSavedPresets = payload.GitHubSavedPresets settings.GitHubDefaultQueryPresets = payload.GitHubDefaultQueryPresets settings.GitLabSavedPresets = payload.GitLabSavedPresets settings.DefaultUtilityAgentID = payload.DefaultUtilityAgentID settings.DefaultUtilityModel = payload.DefaultUtilityModel settings.KeyboardShortcuts = payload.KeyboardShortcuts if settings.KeyboardShortcuts == nil { settings.KeyboardShortcuts = map[string]interface{}{} } if payload.TerminalLinkBehavior != "" { settings.TerminalLinkBehavior = payload.TerminalLinkBehavior } else { settings.TerminalLinkBehavior = "new_tab" } settings.TerminalFontFamily = payload.TerminalFontFamily settings.TerminalFontSize = payload.TerminalFontSize settings.VoiceMode = mergeVoiceModeDefaults(payload.VoiceMode) settings.SystemMetricsDisplay = payload.SystemMetricsDisplay if payload.ChangesPanelLayout == "flat" { settings.ChangesPanelLayout = "flat" } else { settings.ChangesPanelLayout = "tree" } return settings, nil } func normalizeSidebarTaskPrefs(prefs models.SidebarTaskPrefs) models.SidebarTaskPrefs { if prefs.PinnedTaskIDs == nil { prefs.PinnedTaskIDs = []string{} } if prefs.OrderedTaskIDs == nil { prefs.OrderedTaskIDs = []string{} } if prefs.SubtaskOrderByParentID == nil { prefs.SubtaskOrderByParentID = map[string][]string{} } return prefs }