From 68ac7fb973c30157c1a148f4337ddad8db2a91ce Mon Sep 17 00:00:00 2001 From: wizardchen Date: Mon, 18 May 2026 11:21:55 +0800 Subject: [PATCH] feat(user-preferences): implement user preferences management with backend support Added functionality for managing user preferences, including the ability to update preferences via a new API endpoint. The preferences are now stored server-side, allowing for synchronization across devices. Updated relevant components to handle user preferences, including the UserMenu and settings views. Enhanced internationalization support for error messages related to preference updates. --- frontend/src/App.vue | 1 + frontend/src/api/auth/index.ts | 26 +++++++ frontend/src/components/UserMenu.vue | 1 + frontend/src/i18n/locales/en-US.ts | 1 + frontend/src/i18n/locales/ko-KR.ts | 1 + frontend/src/i18n/locales/ru-RU.ts | 1 + frontend/src/i18n/locales/zh-CN.ts | 1 + frontend/src/router/index.ts | 2 + frontend/src/stores/auth.ts | 12 +++ frontend/src/stores/settings.ts | 40 +++++++++- frontend/src/views/auth/Login.vue | 1 + .../src/views/settings/GeneralSettings.vue | 35 +++++---- internal/application/service/user.go | 31 ++++++++ internal/handler/auth.go | 55 ++++++++++++++ internal/router/router.go | 1 + internal/types/interfaces/user.go | 4 + internal/types/user.go | 76 ++++++++++++++++--- migrations/sqlite/000000_init.up.sql | 4 + .../000049_user_preferences.down.sql | 8 ++ .../versioned/000049_user_preferences.up.sql | 24 ++++++ 20 files changed, 301 insertions(+), 24 deletions(-) create mode 100644 migrations/versioned/000049_user_preferences.down.sql create mode 100644 migrations/versioned/000049_user_preferences.up.sql diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 79c69773..305ea0c6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -54,6 +54,7 @@ const syncOIDCUserContext = async () => { avatar: user.avatar, tenant_id: String(user.tenant_id || tenant?.id || ''), can_access_all_tenants: user.can_access_all_tenants || false, + preferences: user.preferences, created_at: user.created_at || new Date().toISOString(), updated_at: user.updated_at || new Date().toISOString() }) diff --git a/frontend/src/api/auth/index.ts b/frontend/src/api/auth/index.ts index e6b86d5a..9456bd01 100644 --- a/frontend/src/api/auth/index.ts +++ b/frontend/src/api/auth/index.ts @@ -77,6 +77,13 @@ export interface RegisterResponse { } } +// 用户偏好(与后端 types.UserPreferences 对齐,字段可选 = 没显式设置过)。 +// 新加 key 时记得:后端 service.UpdateUserPreferences 也要在 merge 分支里 +// 处理;前端调用方按需读 / 默认值降级。 +export interface UserPreferences { + enable_memory?: boolean +} + // 用户信息接口 export interface UserInfo { id: string @@ -85,6 +92,7 @@ export interface UserInfo { avatar?: string tenant_id: string can_access_all_tenants?: boolean + preferences?: UserPreferences created_at: string updated_at: string } @@ -263,6 +271,24 @@ export async function getCurrentUser(): Promise<{ success: boolean; data?: { use } } +/** + * 更新当前用户的偏好设置(PATCH 语义:只发要改的字段,后端只覆盖发了的 key, + * 其它 key 保持不变)。后端会返回更新后的完整 preferences 对象。 + */ +export async function updateMyPreferences( + patch: Partial, +): Promise<{ success: boolean; data?: UserPreferences; message?: string }> { + try { + const response = await put('/api/v1/auth/me/preferences', patch) + return response as unknown as { success: boolean; data?: UserPreferences; message?: string } + } catch (error: any) { + return { + success: false, + message: error.message || t('error.auth.updatePreferencesFailed'), + } + } +} + /** * 获取当前租户信息 */ diff --git a/frontend/src/components/UserMenu.vue b/frontend/src/components/UserMenu.vue index 5890f7b1..98425c78 100644 --- a/frontend/src/components/UserMenu.vue +++ b/frontend/src/components/UserMenu.vue @@ -628,6 +628,7 @@ const loadUserInfo = async () => { avatar: user.avatar, tenant_id: user.tenant_id, can_access_all_tenants: user.can_access_all_tenants || false, + preferences: user.preferences, created_at: user.created_at, updated_at: user.updated_at }) diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index ad16e892..5eaa2732 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -2561,6 +2561,7 @@ export default { registerFailed: 'Registration failed', getUserFailed: 'Failed to get user info', getTenantFailed: 'Failed to get tenant info', + updatePreferencesFailed: 'Failed to update preferences', refreshTokenFailed: 'Token refresh failed', logoutFailed: 'Logout failed', validateTokenFailed: 'Token validation failed', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index ad61a695..1214be6a 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -1811,6 +1811,7 @@ export default { registerFailed: '회원가입 실패', getUserFailed: '사용자 정보 조회 실패', getTenantFailed: '테넌트 정보 조회 실패', + updatePreferencesFailed: '개인 설정 업데이트 실패', refreshTokenFailed: '토큰 갱신 실패', logoutFailed: '로그아웃 실패', validateTokenFailed: '토큰 검증 실패', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index b70e4fb3..88cc2232 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -1607,6 +1607,7 @@ export default { registerFailed: 'Ошибка регистрации', getUserFailed: 'Не удалось получить информацию о пользователе', getTenantFailed: 'Не удалось получить информацию о тенанте', + updatePreferencesFailed: 'Не удалось обновить настройки', refreshTokenFailed: 'Не удалось обновить токен', logoutFailed: 'Ошибка выхода', validateTokenFailed: 'Ошибка проверки токена' diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index b6e11173..210b8787 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -1790,6 +1790,7 @@ export default { registerFailed: "注册失败", getUserFailed: "获取用户信息失败", getTenantFailed: "获取空间信息失败", + updatePreferencesFailed: "更新个性化设置失败", refreshTokenFailed: "刷新Token失败", logoutFailed: "登出失败", validateTokenFailed: "Token验证失败", diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 34b0b2d6..0f879b76 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -162,6 +162,7 @@ function persistLoginResponse(authStore: ReturnType, respon avatar: response.user.avatar, tenant_id: String(response.tenant.id) || '', can_access_all_tenants: response.user.can_access_all_tenants || false, + preferences: response.user.preferences, created_at: response.user.created_at || new Date().toISOString(), updated_at: response.user.updated_at || new Date().toISOString() }) @@ -207,6 +208,7 @@ async function hydrateSessionFromToken(authStore: ReturnType { if (previousId !== userData.id) { reloadUserPreferences() } + // 把后端持久化的 user 偏好(记忆开关等)同步到 settings store。 + // 用 import 而不是顶部 import 避免 stores 间的循环依赖:auth ↔ settings。 + // settings store 只把它当作"本地状态 + localStorage"更新,不会再原路 PUT 回去。 + if (userData.preferences) { + import('@/stores/settings').then(({ useSettingsStore }) => { + useSettingsStore().hydrateFromUserPreferences(userData.preferences) + }).catch(() => { + // 加载 settings store 失败不影响 setUser 主流程;下次 setUser + // 触发时还会再次尝试同步。 + }) + } } const setTenant = (tenantData: TenantInfo) => { @@ -294,6 +305,7 @@ export const useAuthStore = defineStore('auth', () => { avatar: u.avatar, tenant_id: String(u.tenant_id || response.data?.tenant?.id || ''), can_access_all_tenants: u.can_access_all_tenants || false, + preferences: u.preferences, created_at: u.created_at || new Date().toISOString(), updated_at: u.updated_at || new Date().toISOString(), }) diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts index 0fe7af68..ba57fd07 100644 --- a/frontend/src/stores/settings.ts +++ b/frontend/src/stores/settings.ts @@ -1,6 +1,7 @@ import { defineStore } from "pinia"; import { BUILTIN_QUICK_ANSWER_ID, BUILTIN_SMART_REASONING_ID } from "@/api/agent"; import { getApiBaseUrl } from "@/utils/api-base"; +import { updateMyPreferences, type UserPreferences } from "@/api/auth"; // 定义设置接口 interface Settings { @@ -307,10 +308,45 @@ export const useSettingsStore = defineStore("settings", { localStorage.setItem("WeKnora_settings", JSON.stringify(this.settings)); }, - // 启用/禁用记忆功能 - toggleMemory(enabled: boolean) { + // 启用/禁用记忆功能。 + // 现在是"真用户级"开关: + // - 本地缓存 (localStorage) 用作 UI 首屏 / 离线兜底; + // - PUT /auth/me/preferences 是真正的持久化,跨设备/浏览器同步。 + // + // 乐观更新:先翻本地状态让 UI 立刻响应,再异步写后端;失败则回滚 + throw + // 让调用方(GeneralSettings.vue 的 t-switch)可以提示并把开关复位。 + async toggleMemory(enabled: boolean): Promise { + const previous = !!this.settings.enableMemory; this.settings.enableMemory = enabled; localStorage.setItem("WeKnora_settings", JSON.stringify(this.settings)); + + try { + const resp = await updateMyPreferences({ enable_memory: enabled }); + if (!resp.success) { + throw new Error(resp.message || "update failed"); + } + } catch (err) { + // 回滚本地状态,让 UI 复位到旧值。 + this.settings.enableMemory = previous; + localStorage.setItem("WeKnora_settings", JSON.stringify(this.settings)); + throw err; + } + }, + + // 从 /auth/me 或 /auth/login 返回的 user.preferences 同步到本地 settings。 + // 调用方:authStore.setUser(每次登录 / 刷新 user / 切租户后都会触发)。 + // 不写后端,纯本地状态 + localStorage 写入,避免把后端的值再原路 PUT 回去。 + hydrateFromUserPreferences(prefs: UserPreferences | undefined | null) { + if (!prefs) return; + let changed = false; + if (typeof prefs.enable_memory === "boolean" && + this.settings.enableMemory !== prefs.enable_memory) { + this.settings.enableMemory = prefs.enable_memory; + changed = true; + } + if (changed) { + localStorage.setItem("WeKnora_settings", JSON.stringify(this.settings)); + } }, // 启用/禁用自动检查更新 diff --git a/frontend/src/views/auth/Login.vue b/frontend/src/views/auth/Login.vue index ab404d24..3709565a 100755 --- a/frontend/src/views/auth/Login.vue +++ b/frontend/src/views/auth/Login.vue @@ -560,6 +560,7 @@ const persistLoginResponse = async (response: any) => { avatar: response.user.avatar, tenant_id: String(activeTenant.id) || '', can_access_all_tenants: response.user.can_access_all_tenants || false, + preferences: response.user.preferences, created_at: response.user.created_at || new Date().toISOString(), updated_at: response.user.updated_at || new Date().toISOString() }) diff --git a/frontend/src/views/settings/GeneralSettings.vue b/frontend/src/views/settings/GeneralSettings.vue index 1901ecb8..62b0368f 100755 --- a/frontend/src/views/settings/GeneralSettings.vue +++ b/frontend/src/views/settings/GeneralSettings.vue @@ -129,8 +129,9 @@
@@ -238,11 +239,10 @@ const isNeo4jAvailable = computed(() => { return systemInfo.value?.graph_database_engine && systemInfo.value.graph_database_engine !== '未启用' }) -// 记忆功能状态 -const isMemoryEnabled = computed({ - get: () => settingsStore.isMemoryEnabled, - set: (val) => settingsStore.toggleMemory(val) -}) +// 记忆功能状态:只读 computed(toggleMemory 现在是 async + 后端持久化, +// 触发路径统一走 @change → handleMemoryChange,避免 v-model setter 二次调用)。 +const isMemoryEnabled = computed(() => settingsStore.isMemoryEnabled) +const memorySaving = ref(false) // 自动检查更新状态 const isAutoCheckUpdateEnabled = computed({ @@ -275,7 +275,8 @@ onMounted(async () => { const response = await getSystemInfo() systemInfo.value = response.data if (!isNeo4jAvailable.value && settingsStore.isMemoryEnabled) { - settingsStore.toggleMemory(false) + // Neo4j 不可用 → 兜底关掉。后端写入失败不打断主流程(页面级 best-effort)。 + void settingsStore.toggleMemory(false).catch(() => {}) } } catch (error) { console.error('Failed to load system info:', error) @@ -289,15 +290,23 @@ const handleLanguageChange = () => { MessagePlugin.success(t('language.languageSaved')) } -// 处理记忆功能变化 -const handleMemoryChange = (val: boolean) => { +// 处理记忆功能变化。 +// toggleMemory 是 async:先乐观写本地、再 PUT 后端;失败会回滚并 throw。 +// UI 在 saving 期间禁用开关 + 显示 loading,避免用户在请求未完成时反复点。 +const handleMemoryChange = async (val: boolean) => { if (val && !isNeo4jAvailable.value) { MessagePlugin.warning(t('settings.memoryRequiresNeo4j')) - settingsStore.toggleMemory(false) return } - settingsStore.toggleMemory(val) - MessagePlugin.success(t('common.success')) + memorySaving.value = true + try { + await settingsStore.toggleMemory(val) + MessagePlugin.success(t('common.success')) + } catch (err: any) { + MessagePlugin.error(err?.message || t('error.auth.updatePreferencesFailed')) + } finally { + memorySaving.value = false + } } // 处理主题变化 diff --git a/internal/application/service/user.go b/internal/application/service/user.go index d1b6b014..2476f7fc 100644 --- a/internal/application/service/user.go +++ b/internal/application/service/user.go @@ -494,6 +494,37 @@ func (s *userService) UpdateUser(ctx context.Context, user *types.User) error { return s.userRepo.UpdateUser(ctx, user) } +// UpdateUserPreferences applies a partial update over the user's +// preferences blob. PATCH semantics: only keys present in `patch` +// (non-nil pointer fields) replace the existing value; everything else +// is preserved. This lets the front-end PUT only the toggle that +// changed without having to read-modify-write the whole struct, and +// also makes the endpoint forward-compatible — older clients that +// don't know about newer keys won't accidentally erase them. +func (s *userService) UpdateUserPreferences( + ctx context.Context, + userID string, + patch types.UserPreferences, +) (types.UserPreferences, error) { + user, err := s.userRepo.GetUserByID(ctx, userID) + if err != nil { + return types.UserPreferences{}, err + } + + merged := user.Preferences + if patch.EnableMemory != nil { + v := *patch.EnableMemory + merged.EnableMemory = &v + } + + user.Preferences = merged + user.UpdatedAt = time.Now() + if err := s.userRepo.UpdateUser(ctx, user); err != nil { + return types.UserPreferences{}, err + } + return merged, nil +} + // DeleteUser deletes a user func (s *userService) DeleteUser(ctx context.Context, id string) error { return s.userRepo.DeleteUser(ctx, id) diff --git a/internal/handler/auth.go b/internal/handler/auth.go index c82a45a8..94d0c6a7 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -490,6 +490,61 @@ func (h *AuthHandler) GetCurrentUser(c *gin.Context) { }) } +// updateMyPreferencesRequest is the body for PUT /auth/me/preferences. +// Fields are pointers so the handler can distinguish "key not present" +// (preserve existing value) from "explicit false". See +// types.UserPreferences for the persistence-layer counterpart. +type updateMyPreferencesRequest struct { + EnableMemory *bool `json:"enable_memory"` +} + +// UpdateMyPreferences godoc +// @Summary 更新当前用户的个性化设置 +// @Description 按 PATCH 语义合并用户偏好(仅覆盖请求体里出现的字段,其余字段保持不变), +// @Description 数据存放在 users.preferences (JSON),跨设备/浏览器自动同步。 +// @Tags 认证 +// @Accept json +// @Produce json +// @Param request body updateMyPreferencesRequest true "Preferences patch" +// @Success 200 {object} map[string]interface{} "更新后的偏好" +// @Failure 400 {object} errors.AppError "请求参数错误" +// @Failure 401 {object} errors.AppError "未授权" +// @Security Bearer +// @Router /auth/me/preferences [put] +func (h *AuthHandler) UpdateMyPreferences(c *gin.Context) { + ctx := c.Request.Context() + + user, err := h.userService.GetCurrentUser(ctx) + if err != nil { + appErr := errors.NewUnauthorizedError("Failed to get user information").WithDetails(err.Error()) + c.Error(appErr) + return + } + + var req updateMyPreferencesRequest + if err := c.ShouldBindJSON(&req); err != nil { + appErr := errors.NewValidationError("Invalid preferences request").WithDetails(err.Error()) + c.Error(appErr) + return + } + + patch := types.UserPreferences{ + EnableMemory: req.EnableMemory, + } + prefs, err := h.userService.UpdateUserPreferences(ctx, user.ID, patch) + if err != nil { + logger.Errorf(ctx, "Failed to update preferences for user %s: %v", user.Email, err) + appErr := errors.NewBadRequestError("Failed to update preferences").WithDetails(err.Error()) + c.Error(appErr) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": prefs, + }) +} + // ChangePassword godoc // @Summary 修改密码 // @Description 修改当前用户的登录密码 diff --git a/internal/router/router.go b/internal/router/router.go index b1280fdc..9bc1e693 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -654,6 +654,7 @@ func RegisterAuthRoutes(r *gin.RouterGroup, handler *handler.AuthHandler) { r.GET("/auth/validate", handler.ValidateToken) r.POST("/auth/logout", handler.Logout) r.GET("/auth/me", handler.GetCurrentUser) + r.PUT("/auth/me/preferences", handler.UpdateMyPreferences) r.POST("/auth/change-password", handler.ChangePassword) } diff --git a/internal/types/interfaces/user.go b/internal/types/interfaces/user.go index 7542cf36..d43214a4 100644 --- a/internal/types/interfaces/user.go +++ b/internal/types/interfaces/user.go @@ -65,6 +65,10 @@ type UserService interface { GetCurrentUser(ctx context.Context) (*types.User, error) // SearchUsers searches users by username or email SearchUsers(ctx context.Context, query string, limit int) ([]*types.User, error) + // UpdateUserPreferences partially updates the calling user's + // preferences blob (PATCH semantics: only keys present in `patch` + // overwrite existing values). Returns the updated, persisted prefs. + UpdateUserPreferences(ctx context.Context, userID string, patch types.UserPreferences) (types.UserPreferences, error) } // UserRepository defines the user repository interface diff --git a/internal/types/user.go b/internal/types/user.go index 16dbe214..50c732db 100644 --- a/internal/types/user.go +++ b/internal/types/user.go @@ -1,11 +1,63 @@ package types import ( + "database/sql/driver" + "encoding/json" + "errors" "time" "gorm.io/gorm" ) +// UserPreferences holds per-user UI/feature preferences persisted server-side +// so they sync across devices/browsers. Fields are pointers so we can +// distinguish "client didn't send this key" (leave existing value alone) +// from "client explicitly set false" — the partial-update merge in +// UpdateUserPreferences relies on this. +// +// Adding a new preference key: +// 1. Add a *T field below + JSON tag (snake_case, must match the front-end key). +// 2. Extend the merge logic in service.UserService.UpdateUserPreferences. +// 3. Surface the new knob in the frontend settings store. +// No DB DDL is required — preferences is a single jsonb column. +type UserPreferences struct { + // EnableMemory mirrors the "开启记忆功能" switch in General Settings. + // nil = preference never set (treat as feature default = false) + // *false / *true = user explicitly set the toggle. + EnableMemory *bool `json:"enable_memory,omitempty"` +} + +// Value implements driver.Valuer so GORM persists UserPreferences as +// JSON text (Postgres jsonb column / SQLite TEXT). Empty struct serialises +// to "{}", matching the NOT NULL DEFAULT '{}' column constraint. +func (p UserPreferences) Value() (driver.Value, error) { + return json.Marshal(p) +} + +// Scan implements sql.Scanner so GORM can hydrate UserPreferences back +// from the underlying column. Accept []byte (Postgres jsonb / SQLite blob) +// and string (some drivers hand TEXT as string) for portability. +func (p *UserPreferences) Scan(value interface{}) error { + if value == nil { + *p = UserPreferences{} + return nil + } + var data []byte + switch v := value.(type) { + case []byte: + data = v + case string: + data = []byte(v) + default: + return errors.New("UserPreferences.Scan: unsupported type") + } + if len(data) == 0 { + *p = UserPreferences{} + return nil + } + return json.Unmarshal(data, p) +} + // User represents a user in the system type User struct { // Unique identifier of the user @@ -24,6 +76,10 @@ type User struct { IsActive bool `json:"is_active" gorm:"default:true"` // Whether the user can access all tenants (cross-tenant access) CanAccessAllTenants bool `json:"can_access_all_tenants" gorm:"default:false"` + // Per-user UI/feature preferences (memory toggle, future knobs). + // Stored as JSON (jsonb on Postgres, TEXT on SQLite) via the + // driver.Valuer / sql.Scanner methods on UserPreferences. + Preferences UserPreferences `json:"preferences" gorm:"type:jsonb;not null;default:'{}'"` // Creation time of the user CreatedAt time.Time `json:"created_at"` // Last updated time of the user @@ -140,15 +196,16 @@ type RegisterResponse struct { // UserInfo represents user information for API responses type UserInfo struct { - ID string `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - Avatar string `json:"avatar"` - TenantID uint64 `json:"tenant_id"` - IsActive bool `json:"is_active"` - CanAccessAllTenants bool `json:"can_access_all_tenants"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Avatar string `json:"avatar"` + TenantID uint64 `json:"tenant_id"` + IsActive bool `json:"is_active"` + CanAccessAllTenants bool `json:"can_access_all_tenants"` + Preferences UserPreferences `json:"preferences"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ToUserInfo converts User to UserInfo (without sensitive data) @@ -161,6 +218,7 @@ func (u *User) ToUserInfo() *UserInfo { TenantID: u.TenantID, IsActive: u.IsActive, CanAccessAllTenants: u.CanAccessAllTenants, + Preferences: u.Preferences, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, } diff --git a/migrations/sqlite/000000_init.up.sql b/migrations/sqlite/000000_init.up.sql index 50e313b1..ad5a2a9b 100644 --- a/migrations/sqlite/000000_init.up.sql +++ b/migrations/sqlite/000000_init.up.sql @@ -222,6 +222,10 @@ CREATE TABLE IF NOT EXISTS users ( tenant_id INTEGER, is_active BOOLEAN NOT NULL DEFAULT 1, can_access_all_tenants BOOLEAN NOT NULL DEFAULT 0, + -- Per-user JSON preferences (memory toggle, future UI knobs). + -- SQLite has no JSONB; store as TEXT and let GORM (de)serialise via + -- the driver.Valuer / sql.Scanner methods on types.UserPreferences. + preferences TEXT NOT NULL DEFAULT '{}', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME diff --git a/migrations/versioned/000049_user_preferences.down.sql b/migrations/versioned/000049_user_preferences.down.sql new file mode 100644 index 00000000..9eda0169 --- /dev/null +++ b/migrations/versioned/000049_user_preferences.down.sql @@ -0,0 +1,8 @@ +-- Rollback: drop users.preferences (data loss is acceptable; it only +-- carries UI preferences, not anything users can't reset by clicking). + +DO $$ BEGIN RAISE NOTICE '[Migration 000049 DOWN] Dropping users.preferences...'; END $$; + +ALTER TABLE users DROP COLUMN IF EXISTS preferences; + +DO $$ BEGIN RAISE NOTICE '[Migration 000049 DOWN] Done.'; END $$; diff --git a/migrations/versioned/000049_user_preferences.up.sql b/migrations/versioned/000049_user_preferences.up.sql new file mode 100644 index 00000000..7fd323ee --- /dev/null +++ b/migrations/versioned/000049_user_preferences.up.sql @@ -0,0 +1,24 @@ +-- Migration: 000049_user_preferences +-- Adds a user-scoped JSON preferences blob so per-user UI toggles +-- (memory feature, future preferences) can be persisted server-side and +-- sync across devices/browsers. Previously the only carrier for such +-- toggles was the browser's localStorage, which intentionally does NOT +-- cross devices — switching machines silently reset every preference. +-- +-- A single jsonb column keeps the schema flat: new keys can be added by +-- writing through the existing PUT /auth/me/preferences endpoint, no DDL +-- per knob. Server reads/writes are partial updates (key-merge), so an +-- old client that only knows about a subset of keys can't accidentally +-- wipe newer ones it doesn't understand. +-- +-- Defaults to '{}' (NOT NULL) so existing rows behave as "no preference +-- set yet" without forcing every read path to handle NULL. + +DO $$ BEGIN RAISE NOTICE '[Migration 000049] Adding users.preferences column...'; END $$; + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS preferences JSONB NOT NULL DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN users.preferences IS 'Per-user JSON preferences (memory toggle, future UI knobs)'; + +DO $$ BEGIN RAISE NOTICE '[Migration 000049] Done.'; END $$;