75a7063bff
Backend: - Create app/models/user_preference.py with TenantMixin (user_id, key, value JSONB) - Create app/routes/user_preferences.py with GET/PUT/DELETE endpoints + RBAC - Add user_preferences:read/write to CORE_PERMISSIONS - Add user_preferences to legacy role permissions (admin/editor/viewer) - Register route in app/main.py and app/routes/__init__.py - Create alembic migration 0028_user_preferences - Add UserPreference model to conftest.py for test schema - Fix pre-existing conftest seed (Contact industry field removed in migration 0027) Frontend: - Create frontend/src/api/userPreferences.ts with React Query hooks - Create frontend/src/hooks/useUserPreferences.ts syncing with uiStore - Add i18n entries for de.json and en.json Tests: - 13 tests covering CRUD, tenant isolation, CSRF, unauthenticated access - All tests passing
126 lines
3.7 KiB
TypeScript
126 lines
3.7 KiB
TypeScript
/**
|
|
* useUserPreferences — loads user preferences from API and syncs with uiStore.
|
|
*
|
|
* On load: applies server-side preferences to the Zustand uiStore (theme, locale,
|
|
* sidebar state, AI sidebar tab, etc.).
|
|
* On change: persists uiStore state to the server via upsert mutation.
|
|
*/
|
|
|
|
import { useEffect, useCallback } from 'react';
|
|
import { useUIStore, type Theme, type Locale, type AISidebarTab } from '@/store/uiStore';
|
|
import {
|
|
useUserPreferences,
|
|
useUpsertUserPreference,
|
|
useDeleteUserPreference,
|
|
} from '@/api/userPreferences';
|
|
|
|
// Known preference keys that map to uiStore fields
|
|
const PREF_KEYS = {
|
|
THEME: 'theme',
|
|
LOCALE: 'locale',
|
|
SIDEBAR_OPEN: 'sidebar_open',
|
|
AI_SIDEBAR_COLLAPSED: 'ai_sidebar_collapsed',
|
|
AI_SIDEBAR_TAB: 'ai_sidebar_tab',
|
|
MESSAGE_SIDEBAR_COLLAPSED: 'message_sidebar_collapsed',
|
|
SUGGESTION_SIDEBAR_OPEN: 'suggestion_sidebar_open',
|
|
} as const;
|
|
|
|
export function useUserPreferencesSync() {
|
|
const { data, isLoading } = useUserPreferences();
|
|
const upsertMutation = useUpsertUserPreference();
|
|
const deleteMutation = useDeleteUserPreference();
|
|
|
|
const {
|
|
theme,
|
|
locale,
|
|
sidebarOpen,
|
|
aiSidebarCollapsed,
|
|
aiSidebarTab,
|
|
messageSidebarCollapsed,
|
|
suggestionSidebarOpen: _suggestionSidebarOpen,
|
|
setTheme,
|
|
setLocale,
|
|
setSidebarOpen,
|
|
setAISidebarCollapsed,
|
|
setAISidebarTab,
|
|
setMessageSidebarCollapsed,
|
|
} = useUIStore();
|
|
|
|
// Apply server preferences to uiStore on initial load
|
|
useEffect(() => {
|
|
if (!data?.preferences) return;
|
|
|
|
const prefMap = new Map<string, unknown>();
|
|
for (const pref of data.preferences) {
|
|
prefMap.set(pref.key, pref.value);
|
|
}
|
|
|
|
// Only apply if the server has a value — don't override local defaults with null
|
|
if (prefMap.has(PREF_KEYS.THEME)) {
|
|
const serverTheme = prefMap.get(PREF_KEYS.THEME) as Theme;
|
|
if (serverTheme && serverTheme !== theme) {
|
|
setTheme(serverTheme);
|
|
}
|
|
}
|
|
if (prefMap.has(PREF_KEYS.LOCALE)) {
|
|
const serverLocale = prefMap.get(PREF_KEYS.LOCALE) as Locale;
|
|
if (serverLocale && serverLocale !== locale) {
|
|
setLocale(serverLocale);
|
|
}
|
|
}
|
|
if (prefMap.has(PREF_KEYS.SIDEBAR_OPEN)) {
|
|
const serverSidebar = prefMap.get(PREF_KEYS.SIDEBAR_OPEN) as boolean;
|
|
if (serverSidebar !== sidebarOpen) {
|
|
setSidebarOpen(serverSidebar);
|
|
}
|
|
}
|
|
if (prefMap.has(PREF_KEYS.AI_SIDEBAR_COLLAPSED)) {
|
|
const serverCollapsed = prefMap.get(
|
|
PREF_KEYS.AI_SIDEBAR_COLLAPSED
|
|
) as boolean;
|
|
if (serverCollapsed !== aiSidebarCollapsed) {
|
|
setAISidebarCollapsed(serverCollapsed);
|
|
}
|
|
}
|
|
if (prefMap.has(PREF_KEYS.AI_SIDEBAR_TAB)) {
|
|
const serverTab = prefMap.get(PREF_KEYS.AI_SIDEBAR_TAB) as AISidebarTab;
|
|
if (serverTab && serverTab !== aiSidebarTab) {
|
|
setAISidebarTab(serverTab);
|
|
}
|
|
}
|
|
if (prefMap.has(PREF_KEYS.MESSAGE_SIDEBAR_COLLAPSED)) {
|
|
const serverCollapsed = prefMap.get(
|
|
PREF_KEYS.MESSAGE_SIDEBAR_COLLAPSED
|
|
) as boolean;
|
|
if (serverCollapsed !== messageSidebarCollapsed) {
|
|
setMessageSidebarCollapsed(serverCollapsed);
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [data]);
|
|
|
|
// Persist a single preference to the server
|
|
const savePreference = useCallback(
|
|
(key: string, value: unknown) => {
|
|
upsertMutation.mutate({ key, value });
|
|
},
|
|
[upsertMutation]
|
|
);
|
|
|
|
// Delete a preference from the server
|
|
const removePreference = useCallback(
|
|
(key: string) => {
|
|
deleteMutation.mutate(key);
|
|
},
|
|
[deleteMutation]
|
|
);
|
|
|
|
return {
|
|
isLoading,
|
|
savePreference,
|
|
removePreference,
|
|
isSaving: upsertMutation.isPending,
|
|
isDeleting: deleteMutation.isPending,
|
|
};
|
|
}
|