diff --git a/alembic/versions/0028_user_preferences.py b/alembic/versions/0028_user_preferences.py new file mode 100644 index 0000000..bedcbe1 --- /dev/null +++ b/alembic/versions/0028_user_preferences.py @@ -0,0 +1,46 @@ +"""Create user_preferences table for per-user UI settings. + +Revision ID: 0028 +Revises: 0027_unify_company_to_contact +Create Date: 2026-07-23 + +Changes: +- Create user_preferences table with tenant_id, user_id, key, value (JSONB) +- Unique constraint on (tenant_id, user_id, key) +- Indexes on tenant_id+user_id and user_id +- tenant_id column (required by TenantMixin / RLS) +- created_at, updated_at, deleted_at columns (TimestampMixin + SoftDeleteMixin) +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID + + +revision = "0028_user_preferences" +down_revision = "0027_unify_company_to_contact" + + +def upgrade(): + op.create_table( + "user_preferences", + sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("key", sa.String(100), nullable=False), + sa.Column("value", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("tenant_id", "user_id", "key", name="uq_user_prefs_tenant_user_key"), + ) + op.create_index("ix_user_prefs_tenant_user", "user_preferences", ["tenant_id", "user_id"]) + op.create_index("ix_user_prefs_user_id", "user_preferences", ["user_id"]) + op.create_index("ix_user_prefs_tenant_id", "user_preferences", ["tenant_id"]) + + +def downgrade(): + op.drop_index("ix_user_prefs_tenant_id", table_name="user_preferences") + op.drop_index("ix_user_prefs_user_id", table_name="user_preferences") + op.drop_index("ix_user_prefs_tenant_user", table_name="user_preferences") + op.drop_table("user_preferences") diff --git a/app/core/permission_registry.py b/app/core/permission_registry.py index 35f7246..cf05a7c 100644 --- a/app/core/permission_registry.py +++ b/app/core/permission_registry.py @@ -41,6 +41,8 @@ CORE_PERMISSIONS: list[dict[str, str]] = [ {"key": "attachments:delete", "label": "Attachments: Delete", "category": "core", "module": "attachments"}, {"key": "workflows:read", "label": "Workflows: Read", "category": "core", "module": "workflows"}, {"key": "workflows:write", "label": "Workflows: Write", "category": "core", "module": "workflows"}, + {"key": "user_preferences:read", "label": "User Preferences: Read", "category": "core", "module": "user_preferences"}, + {"key": "user_preferences:write", "label": "User Preferences: Write", "category": "core", "module": "user_preferences"}, {"key": "sequences:read", "label": "Sequences: Read", "category": "core", "module": "sequences"}, {"key": "sequences:write", "label": "Sequences: Write", "category": "core", "module": "sequences"}, {"key": "addresses:read", "label": "Addresses: Read", "category": "core", "module": "addresses"}, diff --git a/app/core/permissions.py b/app/core/permissions.py index 2defb84..88ce6b1 100644 --- a/app/core/permissions.py +++ b/app/core/permissions.py @@ -162,12 +162,14 @@ async def resolve_permissions( "sequences:read", "sequences:write", "addresses:read", "addresses:write", "taxes:read", "taxes:write", "currencies:read", "currencies:write", "notifications:read", "notifications:write", "import_export:read", - "import_export:write"} + "import_export:write", + "user_preferences:read", "user_preferences:write"} elif legacy_role == "viewer": allowed |= {"contacts:read", "contacts:read", "users:read", "roles:read", "audit:read", "attachments:read", "workflows:read", "sequences:read", "addresses:read", "taxes:read", "currencies:read", - "notifications:read", "import_export:read"} + "notifications:read", "import_export:read", + "user_preferences:read", "user_preferences:write"} # Load group permissions ug_q = select(UserGroup).where( diff --git a/app/main.py b/app/main.py index ac98232..5f6364a 100644 --- a/app/main.py +++ b/app/main.py @@ -40,6 +40,7 @@ from app.routes import ( roles, tenants, users, + user_preferences, workflows, currencies, taxes, @@ -239,6 +240,7 @@ def create_app() -> FastAPI: app.include_router(plugins.router) app.include_router(ai_copilot.router) app.include_router(workflows.router) + app.include_router(user_preferences.router) app.include_router(currencies.router) app.include_router(taxes.router) app.include_router(sequences.router) diff --git a/app/models/user_preference.py b/app/models/user_preference.py new file mode 100644 index 0000000..187bf66 --- /dev/null +++ b/app/models/user_preference.py @@ -0,0 +1,48 @@ +"""User Preference model — per-user UI settings stored as key/value JSONB, tenant-scoped.""" + +from __future__ import annotations + +import uuid +from typing import Any + +from sqlalchemy import ForeignKey, Index, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base, TenantMixin + + +class UserPreference(Base, TenantMixin): + """Per-user preference entry — stores a single UI preference as JSONB value. + + Keys are arbitrary strings (e.g. 'sidebar_collapsed', 'theme', 'active_tab'). + Values are JSONB to support complex preference structures. + """ + + __tablename__ = "user_preferences" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "user_id", + "key", + name="uq_user_prefs_tenant_user_key", + ), + Index( + "ix_user_prefs_tenant_user", + "tenant_id", + "user_id", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + key: Mapped[str] = mapped_column(String(100), nullable=False) + value: Mapped[Any] = mapped_column(JSONB, nullable=False, default=dict) diff --git a/app/routes/__init__.py b/app/routes/__init__.py index a2b7854..e04f6ca 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -20,5 +20,6 @@ from app.routes import ( roles, # noqa: F401 tenants, # noqa: F401 users, # noqa: F401 + user_preferences, # noqa: F401 workflows, # noqa: F401 ) diff --git a/app/routes/user_preferences.py b/app/routes/user_preferences.py new file mode 100644 index 0000000..6463c9b --- /dev/null +++ b/app/routes/user_preferences.py @@ -0,0 +1,195 @@ +"""User Preferences routes — per-user UI settings via API, tenant-scoped with RBAC.""" + +from __future__ import annotations + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_db +from app.deps import require_permission +from app.models.user_preference import UserPreference + +router = APIRouter(prefix="/api/v1/user/preferences", tags=["user-preferences"]) + + +# ─── Schemas ─── + + +class PreferenceValue(BaseModel): + """Arbitrary JSON value for a preference key.""" + + value: Any = Field(..., description="JSON value for the preference key") + + +class PreferenceResponse(BaseModel): + """Single preference entry response.""" + + key: str + value: Any + updated_at: str | None = None + + +class PreferenceListResponse(BaseModel): + """All preferences for the current user.""" + + preferences: list[PreferenceResponse] + + +# ─── Helpers ─── + + +def _pref_to_response(p: UserPreference) -> PreferenceResponse: + """Convert UserPreference model to response schema.""" + updated_at = None + try: + updated_at = p.updated_at.isoformat() if p.updated_at else None + except Exception: + pass + return PreferenceResponse( + key=p.key, + value=p.value, + updated_at=updated_at, + ) + + +# ─── Endpoints ─── + + +@router.get("") +async def list_user_preferences( + db: AsyncSession = Depends(get_db), + current_user: dict[str, Any] = Depends( + require_permission("user_preferences:read") + ), +) -> PreferenceListResponse: + """Get all preferences for the current user.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + result = await db.execute( + select(UserPreference) + .where( + UserPreference.tenant_id == tenant_id, + UserPreference.user_id == user_id, + UserPreference.deleted_at.is_(None), + ) + .order_by(UserPreference.key) + ) + prefs = result.scalars().all() + return PreferenceListResponse( + preferences=[_pref_to_response(p) for p in prefs] + ) + + +@router.get("/{key}") +async def get_user_preference( + key: str, + db: AsyncSession = Depends(get_db), + current_user: dict[str, Any] = Depends( + require_permission("user_preferences:read") + ), +) -> PreferenceResponse: + """Get a single preference by key.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + result = await db.execute( + select(UserPreference).where( + UserPreference.tenant_id == tenant_id, + UserPreference.user_id == user_id, + UserPreference.key == key, + UserPreference.deleted_at.is_(None), + ) + ) + pref = result.scalar_one_or_none() + if pref is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "detail": f"Preference '{key}' not found", + "code": "not_found", + }, + ) + return _pref_to_response(pref) + + +@router.put("/{key}") +async def upsert_user_preference( + key: str, + body: PreferenceValue, + db: AsyncSession = Depends(get_db), + current_user: dict[str, Any] = Depends( + require_permission("user_preferences:write") + ), +) -> PreferenceResponse: + """Create or update a preference by key (upsert).""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + result = await db.execute( + select(UserPreference).where( + UserPreference.tenant_id == tenant_id, + UserPreference.user_id == user_id, + UserPreference.key == key, + UserPreference.deleted_at.is_(None), + ) + ) + pref = result.scalar_one_or_none() + + if pref is None: + pref = UserPreference( + tenant_id=tenant_id, + user_id=user_id, + key=key, + value=body.value, + ) + db.add(pref) + else: + pref.value = body.value + + await db.commit() + await db.refresh(pref) + return _pref_to_response(pref) + + +@router.delete("/{key}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user_preference( + key: str, + db: AsyncSession = Depends(get_db), + current_user: dict[str, Any] = Depends( + require_permission("user_preferences:write") + ), +): + """Delete a preference by key (soft-delete).""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + result = await db.execute( + select(UserPreference).where( + UserPreference.tenant_id == tenant_id, + UserPreference.user_id == user_id, + UserPreference.key == key, + UserPreference.deleted_at.is_(None), + ) + ) + pref = result.scalar_one_or_none() + if pref is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "detail": f"Preference '{key}' not found", + "code": "not_found", + }, + ) + + # Soft-delete via TenantMixin's deleted_at + from datetime import UTC, datetime + + pref.deleted_at = datetime.now(UTC) + await db.commit() + return None diff --git a/frontend/src/api/userPreferences.ts b/frontend/src/api/userPreferences.ts new file mode 100644 index 0000000..396ee71 --- /dev/null +++ b/frontend/src/api/userPreferences.ts @@ -0,0 +1,74 @@ +/** + * User Preferences API — per-user UI settings stored server-side. + * Matches backend endpoints from /api/v1/user/preferences. + * + * Backend: app/routes/user_preferences.py, app/models/user_preference.py + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPut, apiDelete } from './client'; + +// ── Types ── + +export interface PreferenceEntry { + key: string; + value: unknown; + updated_at?: string | null; +} + +export interface PreferenceListResponse { + preferences: PreferenceEntry[]; +} + +// ── Hooks ── + +export function useUserPreferences() { + return useQuery({ + queryKey: ['userPreferences'], + queryFn: () => + apiGet('/user/preferences'), + }); +} + +export function useUserPreference(key: string) { + return useQuery({ + queryKey: ['userPreferences', key], + queryFn: () => + apiGet(`/user/preferences/${key}`), + enabled: !!key, + }); +} + +export function useUpsertUserPreference() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + key, + value, + }: { + key: string; + value: unknown; + }) => + apiPut(`/user/preferences/${key}`, { + value, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['userPreferences'], + }); + }, + }); +} + +export function useDeleteUserPreference() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (key: string) => + apiDelete(`/user/preferences/${key}`), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['userPreferences'], + }); + }, + }); +} diff --git a/frontend/src/hooks/useUserPreferences.ts b/frontend/src/hooks/useUserPreferences.ts new file mode 100644 index 0000000..63187be --- /dev/null +++ b/frontend/src/hooks/useUserPreferences.ts @@ -0,0 +1,125 @@ +/** + * 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(); + 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, + }; +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 0c0063f..0618489 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -893,6 +893,20 @@ "changes": "Änderungen", "fieldsChanged": "Felder geändert" }, + "userPreferences": { + "title": "Benutzereinstellungen", + "saved": "Einstellungen gespeichert", + "saveError": "Fehler beim Speichern der Einstellungen", + "deleted": "Einstellung gelöscht", + "deleteError": "Fehler beim Löschen der Einstellung", + "loading": "Einstellungen werden geladen", + "theme": "Design", + "language": "Sprache", + "sidebar": "Seitenleiste", + "activeTab": "Aktiver Tab", + "sortOrder": "Sortierreihenfolge", + "synced": "Einstellungen synchronisiert" + }, "ai": { "uiControl": { "active": "KI steuert die UI", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index fd5b8c1..22073f4 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -893,6 +893,20 @@ "changes": "Changes", "fieldsChanged": "fields changed" }, + "userPreferences": { + "title": "User Preferences", + "saved": "Preferences saved", + "saveError": "Failed to save preferences", + "deleted": "Preference deleted", + "deleteError": "Failed to delete preference", + "loading": "Loading preferences", + "theme": "Theme", + "language": "Language", + "sidebar": "Sidebar", + "activeTab": "Active Tab", + "sortOrder": "Sort Order", + "synced": "Preferences synced" + }, "ai": { "uiControl": { "active": "AI is controlling the UI", diff --git a/tests/conftest.py b/tests/conftest.py index d2870e7..c88fc64 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,7 @@ from app.models.plugin import Plugin, PluginMigration # noqa: F401 from app.models.role import Role from app.models.tenant import Tenant from app.models.user import User, UserTenant +from app.models.user_preference import UserPreference # noqa: F401 from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401 from app.plugins.builtins.calendar import CalendarPlugin # noqa: F401 from app.plugins.builtins.calendar.models import ( # noqa: F401 @@ -270,16 +271,18 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]: # Create a company in tenant A company_a = Contact( tenant_id=tenant_a.id, + type="company", name="Company Alpha", - industry="IT", + displayname="Company Alpha", created_by=admin_a.id, updated_by=admin_a.id, ) # Create a company in tenant B company_b = Contact( tenant_id=tenant_b.id, + type="company", name="Company Beta", - industry="Finance", + displayname="Company Beta", created_by=admin_b.id, updated_by=admin_b.id, ) diff --git a/tests/test_user_preferences.py b/tests/test_user_preferences.py new file mode 100644 index 0000000..1992e08 --- /dev/null +++ b/tests/test_user_preferences.py @@ -0,0 +1,232 @@ +"""Tests for User Preferences API — CRUD, tenant isolation, RBAC enforcement.""" + +from __future__ import annotations + +import pytest +from httpx import AsyncClient + +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users + + +async def _login_with_csrf(client: AsyncClient, email: str) -> str: + """Login and return the CSRF token for subsequent unsafe requests.""" + resp = await client.post( + "/api/v1/auth/login", + json={"email": email, "password": "TestPass123!"}, + headers=ORIGIN_HEADER, + ) + assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}" + return resp.json()["csrf_token"] + + +def _csrf_headers(csrf_token: str) -> dict: + """Return headers dict with Origin + X-CSRF-Token for unsafe methods.""" + return {**ORIGIN_HEADER, "X-CSRF-Token": csrf_token} + + +@pytest.mark.asyncio +class TestUserPreferencesList: + """GET /api/v1/user/preferences — list all preferences for current user.""" + + async def test_list_empty_returns_200(self, client: AsyncClient, db_session): + """GET /user/preferences with no preferences → 200 + empty list.""" + await seed_tenant_and_users(db_session) + csrf = await _login_with_csrf(client, "admin@tenanta.com") + resp = await client.get("/api/v1/user/preferences", headers=ORIGIN_HEADER) + assert resp.status_code == 200 + data = resp.json() + assert "preferences" in data + assert data["preferences"] == [] + + async def test_list_returns_saved_preferences(self, client: AsyncClient, db_session): + """GET /user/preferences after PUT → 200 + saved entries.""" + await seed_tenant_and_users(db_session) + csrf = await _login_with_csrf(client, "admin@tenanta.com") + # Save a preference first + resp = await client.put( + "/api/v1/user/preferences/theme", + json={"value": "dark"}, + headers=_csrf_headers(csrf), + ) + assert resp.status_code == 200 + resp = await client.get("/api/v1/user/preferences", headers=ORIGIN_HEADER) + assert resp.status_code == 200 + data = resp.json() + assert len(data["preferences"]) >= 1 + keys = [p["key"] for p in data["preferences"]] + assert "theme" in keys + + async def test_list_unauthenticated_returns_401(self, client: AsyncClient): + """GET /user/preferences without auth → 401.""" + resp = await client.get("/api/v1/user/preferences") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +class TestUserPreferencesGet: + """GET /api/v1/user/preferences/{key} — get single preference.""" + + async def test_get_existing_preference_returns_200(self, client: AsyncClient, db_session): + """GET /user/preferences/{key} after PUT → 200 + value.""" + await seed_tenant_and_users(db_session) + csrf = await _login_with_csrf(client, "admin@tenanta.com") + await client.put( + "/api/v1/user/preferences/sidebar_open", + json={"value": False}, + headers=_csrf_headers(csrf), + ) + resp = await client.get( + "/api/v1/user/preferences/sidebar_open", headers=ORIGIN_HEADER + ) + assert resp.status_code == 200 + data = resp.json() + assert data["key"] == "sidebar_open" + assert data["value"] is False + + async def test_get_nonexistent_returns_404(self, client: AsyncClient, db_session): + """GET /user/preferences/{key} for missing key → 404.""" + await seed_tenant_and_users(db_session) + await _login_with_csrf(client, "admin@tenanta.com") + resp = await client.get( + "/api/v1/user/preferences/nonexistent", headers=ORIGIN_HEADER + ) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +class TestUserPreferencesUpsert: + """PUT /api/v1/user/preferences/{key} — create or update preference.""" + + async def test_create_new_preference_returns_200(self, client: AsyncClient, db_session): + """PUT /user/preferences/{key} with new key → 200 + created entry.""" + await seed_tenant_and_users(db_session) + csrf = await _login_with_csrf(client, "admin@tenanta.com") + resp = await client.put( + "/api/v1/user/preferences/theme", + json={"value": "dark"}, + headers=_csrf_headers(csrf), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["key"] == "theme" + assert data["value"] == "dark" + assert "updated_at" in data + + async def test_update_existing_preference_returns_200(self, client: AsyncClient, db_session): + """PUT /user/preferences/{key} with existing key → 200 + updated value.""" + await seed_tenant_and_users(db_session) + csrf = await _login_with_csrf(client, "admin@tenanta.com") + # Create + await client.put( + "/api/v1/user/preferences/locale", + json={"value": "de"}, + headers=_csrf_headers(csrf), + ) + # Update + resp = await client.put( + "/api/v1/user/preferences/locale", + json={"value": "en"}, + headers=_csrf_headers(csrf), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["key"] == "locale" + assert data["value"] == "en" + + async def test_upsert_complex_json_value(self, client: AsyncClient, db_session): + """PUT /user/preferences/{key} with complex JSON value → 200.""" + await seed_tenant_and_users(db_session) + csrf = await _login_with_csrf(client, "admin@tenanta.com") + complex_value = { + "sort_by": "name", + "sort_order": "asc", + "filters": {"industry": "IT", "status": "active"}, + } + resp = await client.put( + "/api/v1/user/preferences/contact_list_settings", + json={"value": complex_value}, + headers=_csrf_headers(csrf), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["value"]["sort_by"] == "name" + assert data["value"]["filters"]["industry"] == "IT" + + async def test_upsert_unauthenticated_returns_401(self, client: AsyncClient): + """PUT /user/preferences/{key} without auth → 401 or 403 (CSRF block).""" + resp = await client.put( + "/api/v1/user/preferences/theme", + json={"value": "dark"}, + headers=ORIGIN_HEADER, + ) + # Without session, CSRF middleware blocks with 403 before auth check + assert resp.status_code in (401, 403) + + +@pytest.mark.asyncio +class TestUserPreferencesDelete: + """DELETE /api/v1/user/preferences/{key} — remove a preference.""" + + async def test_delete_existing_returns_204(self, client: AsyncClient, db_session): + """DELETE /user/preferences/{key} for existing key → 204.""" + await seed_tenant_and_users(db_session) + csrf = await _login_with_csrf(client, "admin@tenanta.com") + # Create first + await client.put( + "/api/v1/user/preferences/theme", + json={"value": "dark"}, + headers=_csrf_headers(csrf), + ) + resp = await client.delete( + "/api/v1/user/preferences/theme", headers=_csrf_headers(csrf) + ) + assert resp.status_code == 204 + + async def test_delete_nonexistent_returns_404(self, client: AsyncClient, db_session): + """DELETE /user/preferences/{key} for missing key → 404.""" + await seed_tenant_and_users(db_session) + csrf = await _login_with_csrf(client, "admin@tenanta.com") + resp = await client.delete( + "/api/v1/user/preferences/nonexistent", headers=_csrf_headers(csrf) + ) + assert resp.status_code == 404 + + async def test_delete_then_get_returns_404(self, client: AsyncClient, db_session): + """After DELETE, GET /user/preferences/{key} → 404.""" + await seed_tenant_and_users(db_session) + csrf = await _login_with_csrf(client, "admin@tenanta.com") + await client.put( + "/api/v1/user/preferences/locale", + json={"value": "de"}, + headers=_csrf_headers(csrf), + ) + await client.delete( + "/api/v1/user/preferences/locale", headers=_csrf_headers(csrf) + ) + resp = await client.get( + "/api/v1/user/preferences/locale", headers=ORIGIN_HEADER + ) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +class TestUserPreferencesTenantIsolation: + """Preferences are tenant-scoped — users in different tenants can't see each other.""" + + async def test_preferences_isolated_per_user(self, client: AsyncClient, db_session): + """User A's preferences are not visible to User B in the same tenant.""" + await seed_tenant_and_users(db_session) + # Admin saves a preference + csrf_admin = await _login_with_csrf(client, "admin@tenanta.com") + await client.put( + "/api/v1/user/preferences/theme", + json={"value": "dark"}, + headers=_csrf_headers(csrf_admin), + ) + # Viewer logs in — should not see admin's preferences + csrf_viewer = await _login_with_csrf(client, "viewer@tenanta.com") + resp = await client.get("/api/v1/user/preferences", headers=ORIGIN_HEADER) + assert resp.status_code == 200 + data = resp.json() + keys = [p["key"] for p in data["preferences"]] + assert "theme" not in keys