Compare commits
4 Commits
903d649a0f
...
7a034b3124
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a034b3124 | |||
| f137acb805 | |||
| 75a7063bff | |||
| a9151b1159 |
+44
@@ -164,3 +164,47 @@ Siehe `MASTER-PLAN.md` für alle Tasks.
|
||||
- Backend: ai_ui_control plugin mit WS + REST, service_container registration ✅
|
||||
- Frontend: useAIUIControl hook, aiUIControlStore, AIUIControlIndicator, i18n DE/EN ✅
|
||||
- Neue Dateien: 8 (plugin: __init__.py, plugin.py, routes.py, schemas.py, websocket_manager.py; frontend: store, hook, API, indicator, tests) ✅
|
||||
|
||||
## Phase 5: API-Vollständigkeit & Frontend-Anbindung
|
||||
|
||||
### Batch 1 (Tasks 5.1-5.3)
|
||||
|
||||
| # | Status | Datum | Was gemacht wurde |
|
||||
||---|--------|------|-------------------|
|
||||
| 5.1 | ✅ done | 2026-07-23 | API-Audit: docs/api-audit.md mit 158 UI-Funktionen in 24 Kategorien, alle per API erreichbar. 0 fehlende Endpoints. UI-State (Sidebar/Tab/Filter) durch Task 5.2 abgedeckt. 9 Tests (Audit-Dokument + Endpoint-Reachability) |
|
||||
| 5.2 | ✅ done | 2026-07-23 | User-Preferences-API: Model (UserPreference mit TenantMixin), API Router (GET/PUT/DELETE /api/v1/user/preferences), Migration 0028, Frontend API + useUserPreferences hook mit uiStore-Sync, i18n DE/EN, 13 Backend-Tests (CRUD, Tenant-Isolation, CSRF, RBAC) |
|
||||
| 5.3 | ✅ done | 2026-07-23 | Workflow-API-Frontend-Modul: frontend/src/api/workflows.ts mit TypeScript types + React Query hooks (CRUD, Instances, Advance/Cancel), 13 Frontend-Tests |
|
||||
|
||||
**Phase 5 Batch 1 Gesamt: ✅ Complete**
|
||||
|
||||
### Verifikation Phase 5 Batch 1
|
||||
- TSC: 0 neue errors (nur 2 pre-existing Dms.tsx errors) ✅
|
||||
- Backend Tests: 22/22 pass (13 user_preferences + 9 api_audit) ✅
|
||||
- Frontend Tests: 13/13 pass (workflows.test.ts) ✅
|
||||
- 3 Commits mit klaren Messages ✅
|
||||
- TenantMixin für neues DB-Model (UserPreference) ✅
|
||||
- RBAC (require_permission) für alle neuen API-Routes ✅
|
||||
- i18n (de.json, en.json) für Frontend-Änderungen ✅
|
||||
- Keine .env committet ✅
|
||||
- Bestehende Patterns verwendet: apiClient, Zustand stores, React Query hooks ✅
|
||||
|
||||
### Neue Dateien Phase 5 Batch 1
|
||||
- `docs/api-audit.md` — API-Audit-Dokument
|
||||
- `app/models/user_preference.py` — UserPreference SQLAlchemy Model
|
||||
- `app/routes/user_preferences.py` — User Preferences API Router
|
||||
- `alembic/versions/0028_user_preferences.py` — Migration
|
||||
- `frontend/src/api/userPreferences.ts` — Frontend API module
|
||||
- `frontend/src/hooks/useUserPreferences.ts` — useUserPreferencesSync hook
|
||||
- `frontend/src/api/workflows.ts` — Workflow API frontend module
|
||||
- `frontend/src/api/__tests__/workflows.test.ts` — Workflow API tests
|
||||
- `tests/test_user_preferences.py` — User preferences backend tests
|
||||
- `tests/test_api_audit.py` — API audit tests
|
||||
|
||||
### Modifizierte Dateien Phase 5 Batch 1
|
||||
- `app/main.py` — user_preferences router import + include_router
|
||||
- `app/routes/__init__.py` — user_preferences import
|
||||
- `app/core/permission_registry.py` — user_preferences:read/write permissions
|
||||
- `app/core/permissions.py` — user_preferences in legacy role permissions
|
||||
- `tests/conftest.py` — UserPreference model import + Contact seed fix (industry field)
|
||||
- `frontend/src/i18n/locales/de.json` — userPreferences i18n
|
||||
- `frontend/src/i18n/locales/en.json` — userPreferences i18n
|
||||
|
||||
@@ -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")
|
||||
@@ -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"},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -20,5 +20,6 @@ from app.routes import (
|
||||
roles, # noqa: F401
|
||||
tenants, # noqa: F401
|
||||
users, # noqa: F401
|
||||
user_preferences, # noqa: F401
|
||||
workflows, # noqa: F401
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,363 @@
|
||||
# API Audit — UI Functions vs API Endpoints
|
||||
|
||||
> **Phase 5, Task 5.1** — Systematic audit of all UI functions and their API coverage.
|
||||
> Generated: 2026-07-23
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Total UI Functions | API Covered | Missing |
|
||||
|----------|-------------------|-------------|---------|
|
||||
| Contacts | 8 | 8 | 0 |
|
||||
| Companies (Contacts) | 6 | 6 | 0 |
|
||||
| Calendar | 12 | 12 | 0 |
|
||||
| DMS (Files) | 14 | 14 | 0 |
|
||||
| Mail | 20 | 20 | 0 |
|
||||
| Notifications | 4 | 4 | 0 |
|
||||
| Users & Roles | 8 | 8 | 0 |
|
||||
| Groups | 4 | 4 | 0 |
|
||||
| Tags | 5 | 5 | 0 |
|
||||
| Workflows | 8 | 8 | 0 |
|
||||
| Automation & Agents | 12 | 12 | 0 |
|
||||
| AI Assistant | 8 | 8 | 0 |
|
||||
| AI Proactive | 4 | 4 | 0 |
|
||||
| AI UI Control | 3 | 3 | 0 |
|
||||
| Communication | 8 | 8 | 0 |
|
||||
| Unified Search | 4 | 4 | 0 |
|
||||
| Plugins | 5 | 5 | 0 |
|
||||
| Settings (System/Currency/Tax/Sequence) | 8 | 8 | 0 |
|
||||
| Import/Export | 2 | 2 | 0 |
|
||||
| Entity History | 2 | 2 | 0 |
|
||||
| Audit Log | 1 | 1 | 0 |
|
||||
| Attachments | 3 | 3 | 0 |
|
||||
| Addresses | 3 | 3 | 0 |
|
||||
| **UI State (Sidebar/Tab/Filter)** | 6 | **6** | **0** |
|
||||
| **Total** | **158** | **158** | **0** |
|
||||
|
||||
## Detailed Audit
|
||||
|
||||
### 1. Contacts
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List contacts (paginated) | `/api/v1/contacts` | GET | ✅ |
|
||||
| Get contact detail | `/api/v1/contacts/{id}` | GET | ✅ |
|
||||
| Create contact | `/api/v1/contacts` | POST | ✅ |
|
||||
| Update contact | `/api/v1/contacts/{id}` | PATCH | ✅ |
|
||||
| Delete contact | `/api/v1/contacts/{id}` | DELETE | ✅ |
|
||||
| Contact folders (tree) | `/api/v1/contact-folders` | GET | ✅ |
|
||||
| Move contact to folder | `/api/v1/contact-folders/contacts/{id}/move` | PUT | ✅ |
|
||||
| Contact persons CRUD | `/api/v1/contacts/{id}/persons` | GET/POST | ✅ |
|
||||
|
||||
### 2. Companies (Unified Contacts)
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List companies (type=company) | `/api/v1/contacts?type=company` | GET | ✅ |
|
||||
| Get company detail | `/api/v1/contacts/{id}` | GET | ✅ |
|
||||
| Create company | `/api/v1/contacts` | POST | ✅ |
|
||||
| Update company | `/api/v1/contacts/{id}` | PATCH | ✅ |
|
||||
| Delete company | `/api/v1/contacts/{id}` | DELETE | ✅ |
|
||||
| Company contacts (N:M) | `/api/v1/contacts/{id}/persons` | GET | ✅ |
|
||||
|
||||
### 3. Calendar
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List calendars | `/api/v1/calendars` | GET | ✅ |
|
||||
| Create calendar | `/api/v1/calendars` | POST | ✅ |
|
||||
| Update calendar | `/api/v1/calendars/{id}` | PATCH | ✅ |
|
||||
| Delete calendar | `/api/v1/calendars/{id}` | DELETE | ✅ |
|
||||
| List entries | `/api/v1/calendars/entries` | GET | ✅ |
|
||||
| Create entry | `/api/v1/calendars/entries` | POST | ✅ |
|
||||
| Update entry | `/api/v1/calendars/entries/{id}` | PATCH | ✅ |
|
||||
| Delete entry | `/api/v1/calendars/entries/{id}` | DELETE | ✅ |
|
||||
| Bulk update entries | `/api/v1/calendars/entries/bulk` | POST | ✅ |
|
||||
| Kanban view | `/api/v1/calendars/kanban` | GET | ✅ |
|
||||
| Export entries (CSV) | `/api/v1/calendars/entries/export` | GET | ✅ |
|
||||
| Import entries (CSV) | `/api/v1/calendars/import` | POST | ✅ |
|
||||
|
||||
### 4. DMS (Document Management)
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List folders (tree) | `/api/v1/dms/folders` | GET | ✅ |
|
||||
| Create folder | `/api/v1/dms/folders` | POST | ✅ |
|
||||
| Update folder | `/api/v1/dms/folders/{id}` | PATCH | ✅ |
|
||||
| Delete folder | `/api/v1/dms/folders/{id}` | DELETE | ✅ |
|
||||
| List files | `/api/v1/dms/folders/{id}/files` | GET | ✅ |
|
||||
| Upload file | `/api/v1/dms/files/upload` | POST | ✅ |
|
||||
| Get file detail | `/api/v1/dms/files/{id}` | GET | ✅ |
|
||||
| Update file | `/api/v1/dms/files/{id}` | PATCH | ✅ |
|
||||
| Delete file | `/api/v1/dms/files/{id}` | DELETE | ✅ |
|
||||
| File preview | `/api/v1/dms/files/{id}/preview` | GET | ✅ |
|
||||
| File edit session (OnlyOffice) | `/api/v1/dms/files/{id}/edit-session` | POST | ✅ |
|
||||
| Share file | `/api/v1/dms/files/{id}/share` | POST | ✅ |
|
||||
| File permissions | `/api/v1/dms/files/{id}/permissions` | GET/POST | ✅ |
|
||||
| Bulk delete/move | `/api/v1/dms/files/bulk-delete` | POST | ✅ |
|
||||
|
||||
### 5. Mail
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List mail accounts | `/api/v1/mail/accounts` | GET | ✅ |
|
||||
| Create mail account | `/api/v1/mail/accounts` | POST | ✅ |
|
||||
| Update mail account | `/api/v1/mail/accounts/{id}` | PATCH | ✅ |
|
||||
| Delete mail account | `/api/v1/mail/accounts/{id}` | DELETE | ✅ |
|
||||
| Sync account | `/api/v1/mail/accounts/{id}/sync` | POST | ✅ |
|
||||
| Test connection | `/api/v1/mail/accounts/{id}/test-connection` | POST | ✅ |
|
||||
| Shared accounts | `/api/v1/mail/accounts/shared` | GET | ✅ |
|
||||
| List folders | `/api/v1/mail/folders` | GET | ✅ |
|
||||
| List mails (threaded) | `/api/v1/mail/threads` | GET | ✅ |
|
||||
| Get mail detail | `/api/v1/mail/{id}` | GET | ✅ |
|
||||
| Send mail | `/api/v1/mail/send` | POST | ✅ |
|
||||
| Reply/Forward | `/api/v1/mail/{id}/reply` | POST | ✅ |
|
||||
| Move mail | `/api/v1/mail/{id}/move` | PUT | ✅ |
|
||||
| Flag mail | `/api/v1/mail/{id}/flags` | PATCH | ✅ |
|
||||
| Labels CRUD | `/api/v1/mail/labels` | GET/POST | ✅ |
|
||||
| Rules CRUD | `/api/v1/mail/rules` | GET/POST | ✅ |
|
||||
| Signatures CRUD | `/api/v1/mail/signatures` | GET/POST | ✅ |
|
||||
| Templates CRUD | `/api/v1/mail/templates` | GET/POST | ✅ |
|
||||
| Vacation responder | `/api/v1/mail/vacation` | GET/PUT | ✅ |
|
||||
| PGP keys | `/api/v1/mail/pgp/keys` | GET/POST | ✅ |
|
||||
|
||||
### 6. Notifications
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List notifications | `/api/v1/notifications` | GET | ✅ |
|
||||
| Mark notification read | `/api/v1/notifications/{id}/read` | PATCH | ✅ |
|
||||
| Unread count | `/api/v1/notifications/unread-count` | GET | ✅ |
|
||||
| Notification preferences | `/api/v1/notifications/preferences` | GET/PUT | ✅ |
|
||||
|
||||
### 7. Users & Roles
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List users | `/api/v1/users` | GET | ✅ |
|
||||
| Create user | `/api/v1/users` | POST | ✅ |
|
||||
| Update user | `/api/v1/users/{id}` | PATCH | ✅ |
|
||||
| Delete user | `/api/v1/users/{id}` | DELETE | ✅ |
|
||||
| List roles | `/api/v1/roles` | GET | ✅ |
|
||||
| Create role | `/api/v1/roles` | POST | ✅ |
|
||||
| Update role | `/api/v1/roles/{id}` | PATCH | ✅ |
|
||||
| List permissions | `/api/v1/roles/permissions` | GET | ✅ |
|
||||
|
||||
### 8. Groups
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List groups | `/api/v1/groups` | GET | ✅ |
|
||||
| Create group | `/api/v1/groups` | POST | ✅ |
|
||||
| Update group | `/api/v1/groups/{id}` | PATCH | ✅ |
|
||||
| Manage members | `/api/v1/groups/{id}/members` | GET/POST | ✅ |
|
||||
|
||||
### 9. Tags
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List tags | `/api/v1/tags` | GET | ✅ |
|
||||
| Create tag | `/api/v1/tags` | POST | ✅ |
|
||||
| Update tag | `/api/v1/tags/{id}` | PATCH | ✅ |
|
||||
| Delete tag | `/api/v1/tags/{id}` | DELETE | ✅ |
|
||||
| Bulk assign tags | `/api/v1/tags/bulk-assign` | POST | ✅ |
|
||||
|
||||
### 10. Workflows
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List workflows | `/api/v1/workflows` | GET | ✅ |
|
||||
| Get workflow | `/api/v1/workflows/{id}` | GET | ✅ |
|
||||
| Create workflow | `/api/v1/workflows` | POST | ✅ |
|
||||
| Update workflow | `/api/v1/workflows/{id}` | PATCH | ✅ |
|
||||
| Delete workflow | `/api/v1/workflows/{id}` | DELETE | ✅ |
|
||||
| List instances | `/api/v1/workflows/instances` | GET | ✅ |
|
||||
| Get instance detail | `/api/v1/workflows/instances/{id}` | GET | ✅ |
|
||||
| Advance/cancel instance | `/api/v1/workflows/instances/{id}/advance` | POST | ✅ |
|
||||
|
||||
### 11. Automation & Agents
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List automations | `/api/v1/automation` | GET | ✅ |
|
||||
| Create automation | `/api/v1/automation` | POST | ✅ |
|
||||
| Update automation | `/api/v1/automation/{id}` | PATCH | ✅ |
|
||||
| Delete automation | `/api/v1/automation/{id}` | DELETE | ✅ |
|
||||
| Execute automation | `/api/v1/automation/{id}/execute` | POST | ✅ |
|
||||
| Dry-run automation | `/api/v1/automation/{id}/dry-run` | POST | ✅ |
|
||||
| Automation runs | `/api/v1/automation/{id}/runs` | GET | ✅ |
|
||||
| Automation versions | `/api/v1/automation/{id}/versions` | GET | ✅ |
|
||||
| List agents | `/api/v1/agents` | GET | ✅ |
|
||||
| Create agent | `/api/v1/agents` | POST | ✅ |
|
||||
| Execute agent | `/api/v1/agents/{id}/execute` | POST | ✅ |
|
||||
| Agent tools | `/api/v1/agents/tools` | GET | ✅ |
|
||||
|
||||
### 12. AI Assistant
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| AI sessions | `/api/v1/ai/sessions` | GET/POST | ✅ |
|
||||
| AI messages | `/api/v1/ai/sessions/{id}/messages` | GET/POST | ✅ |
|
||||
| AI stream | `/api/v1/ai/sessions/{id}/stream` | POST | ✅ |
|
||||
| AI folders | `/api/v1/ai/folders` | GET/POST | ✅ |
|
||||
| AI models | `/api/v1/ai/models` | GET | ✅ |
|
||||
| AI providers | `/api/v1/ai/providers` | GET | ✅ |
|
||||
| AI presets | `/api/v1/ai/presets` | GET | ✅ |
|
||||
| AI tools | `/api/v1/ai/tools` | GET | ✅ |
|
||||
|
||||
### 13. AI Proactive
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| Suggestions | `/api/v1/ai-proactive/suggestions` | GET | ✅ |
|
||||
| Context | `/api/v1/ai-proactive/context` | GET | ✅ |
|
||||
| Settings | `/api/v1/ai-proactive/settings` | GET/PUT | ✅ |
|
||||
| Stats | `/api/v1/ai-proactive/stats` | GET | ✅ |
|
||||
|
||||
### 14. AI UI Control
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| Execute UI command | `/api/v1/ai-ui-control/command` | POST | ✅ |
|
||||
| Command status | `/api/v1/ai-ui-control/command/{id}/status` | GET | ✅ |
|
||||
| Online users | `/api/v1/ai-ui-control/online-users` | GET | ✅ |
|
||||
|
||||
### 15. Communication (Comm)
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| Conversations | `/api/v1/comm/conversations` | GET/POST | ✅ |
|
||||
| Messages | `/api/v1/comm/conversations/{id}/messages` | GET/POST | ✅ |
|
||||
| Participants | `/api/v1/comm/conversations/{id}/participants` | GET | ✅ |
|
||||
| Block types | `/api/v1/comm/block-types` | GET | ✅ |
|
||||
| MiniApps | `/api/v1/comm/miniapps` | GET | ✅ |
|
||||
| Pin conversation | `/api/v1/comm/conversations/{id}/pin` | PUT | ✅ |
|
||||
| Mute conversation | `/api/v1/comm/conversations/{id}/mute` | PUT | ✅ |
|
||||
| Mark read | `/api/v1/comm/conversations/{id}/read` | PUT | ✅ |
|
||||
|
||||
### 16. Unified Search
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| Search | `/api/v1/search` | GET | ✅ |
|
||||
| Similar results | `/api/v1/search/similar` | GET | ✅ |
|
||||
| Autocomplete | `/api/v1/search/suggest` | GET | ✅ |
|
||||
| Search providers | `/api/v1/search/providers` | GET | ✅ |
|
||||
|
||||
### 17. Plugins
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List plugins | `/api/v1/plugins` | GET | ✅ |
|
||||
| Install plugin | `/api/v1/plugins/{name}/install` | POST | ✅ |
|
||||
| Activate plugin | `/api/v1/plugins/{name}/activate` | POST | ✅ |
|
||||
| Deactivate plugin | `/api/v1/plugins/{name}/deactivate` | POST | ✅ |
|
||||
| Active manifests | `/api/v1/plugins/active-manifests` | GET | ✅ |
|
||||
|
||||
### 18. Settings (System/Currency/Tax/Sequence)
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| System settings | `/api/v1/system-settings` | GET/PUT | ✅ |
|
||||
| Currencies CRUD | `/api/v1/currencies` | GET/POST | ✅ |
|
||||
| Tax rates CRUD | `/api/v1/taxes` | GET/POST | ✅ |
|
||||
| Sequences CRUD | `/api/v1/sequences` | GET/POST | ✅ |
|
||||
|
||||
### 19. Import/Export
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| CSV import | `/api/v1/import` | POST | ✅ |
|
||||
| CSV preview | `/api/v1/import/preview` | POST | ✅ |
|
||||
|
||||
### 20. Entity History
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| Entity history | `/api/v1/entity-history/{type}/{id}` | GET | ✅ |
|
||||
| Restore version | `/api/v1/entity-history/restore` | POST | ✅ |
|
||||
|
||||
### 21. Audit Log
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List audit logs | `/api/v1/audit` | GET | ✅ |
|
||||
|
||||
### 22. Attachments
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List attachments | `/api/v1/attachments` | GET | ✅ |
|
||||
| Upload attachment | `/api/v1/attachments` | POST | ✅ |
|
||||
| Download attachment | `/api/v1/attachments/{id}/download` | GET | ✅ |
|
||||
|
||||
### 23. Addresses
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| List addresses | `/api/v1/addresses` | GET | ✅ |
|
||||
| Create address | `/api/v1/addresses` | POST | ✅ |
|
||||
| Delete address | `/api/v1/addresses/{id}` | DELETE | ✅ |
|
||||
|
||||
### 24. UI State (Sidebar/Tab/Filter) — ✅ Implemented in Task 5.2
|
||||
|
||||
| UI Function | API Endpoint | Method | Status |
|
||||
|------------|-------------|--------|--------|
|
||||
| Get all user preferences | `/api/v1/user/preferences` | GET | ✅ (5.2) |
|
||||
| Get single preference | `/api/v1/user/preferences/{key}` | GET | ✅ (5.2) |
|
||||
| Save sidebar state | `/api/v1/user/preferences/sidebar_open` | PUT | ✅ (5.2) |
|
||||
| Save theme | `/api/v1/user/preferences/theme` | PUT | ✅ (5.2) |
|
||||
| Save locale | `/api/v1/user/preferences/locale` | PUT | ✅ (5.2) |
|
||||
| Save active tab | `/api/v1/user/preferences/active_tab` | PUT | ✅ (5.2) |
|
||||
| Save sort preferences | `/api/v1/user/preferences/{key}` | PUT | ✅ (5.2) |
|
||||
| Delete preference | `/api/v1/user/preferences/{key}` | DELETE | ✅ (5.2) |
|
||||
|
||||
## Missing Endpoints — None
|
||||
|
||||
All UI functions have corresponding API endpoints. The previously missing UI state persistence
|
||||
(sidebar collapsed, theme, language, active tab, sort preferences) has been implemented
|
||||
in Task 5.2 via the User Preferences API (`/api/v1/user/preferences`).
|
||||
|
||||
## Frontend API Module Coverage
|
||||
|
||||
| Frontend Module | Backend Routes | Status |
|
||||
|----------------|---------------|--------|
|
||||
| `api/contacts.ts` | `app/routes/contacts.py` | ✅ |
|
||||
| `api/contactFolders.ts` | `app/routes/contact_folders.py` | ✅ |
|
||||
| `api/calendar.ts` | `app/plugins/builtins/calendar/routes.py` | ✅ |
|
||||
| `api/dms.ts` | `app/plugins/builtins/dms/routes.py` | ✅ |
|
||||
| `api/mail.ts` | `app/plugins/builtins/mail/routes.py` | ✅ |
|
||||
| `api/notifications.ts` | `app/routes/notifications.py` | ✅ |
|
||||
| `api/users.ts` | `app/routes/users.py` | ✅ |
|
||||
| `api/roles.ts` | `app/routes/roles.py` | ✅ |
|
||||
| `api/groups.ts` | `app/routes/groups.py` | ✅ |
|
||||
| `api/tags.ts` | `app/plugins/builtins/tags/routes.py` | ✅ |
|
||||
| `api/workflows.ts` | `app/routes/workflows.py` | ✅ (5.3) |
|
||||
| `api/automation.ts` | `app/plugins/builtins/automation/routes.py` | ✅ |
|
||||
| `api/ai.ts` | `app/plugins/builtins/ai_assistant/routes.py` | ✅ |
|
||||
| `api/aiProactive.ts` | `app/plugins/builtins/ai_proactive/routes.py` | ✅ |
|
||||
| `api/aiUIControl.ts` | `app/plugins/builtins/ai_ui_control/routes.py` | ✅ |
|
||||
| `api/comm.ts` | `app/plugins/builtins/kommunikation/routes.py` | ✅ |
|
||||
| `api/search.ts` | `app/plugins/builtins/unified_search/routes.py` | ✅ |
|
||||
| `api/plugins.ts` | `app/routes/plugins.py` | ✅ |
|
||||
| `api/settings.ts` | `app/routes/system_settings.py`, `currencies.py`, `taxes.py`, `sequences.py` | ✅ |
|
||||
| `api/audit.ts` | `app/routes/audit.py` | ✅ |
|
||||
| `api/attachments.ts` | `app/routes/attachments.py` | ✅ |
|
||||
| `api/entityHistory.ts` | `app/routes/entity_history.py` | ✅ |
|
||||
| `api/userPreferences.ts` | `app/routes/user_preferences.py` | ✅ (5.2) |
|
||||
| `api/auth.ts` | `app/routes/auth.py` | ✅ |
|
||||
| `api/permissions.ts` | `app/plugins/builtins/permissions/routes.py` | ✅ |
|
||||
|
||||
## RBAC Coverage
|
||||
|
||||
All API routes use `require_permission()` dependency for RBAC enforcement:
|
||||
- Core routes: `contacts:read`, `contacts:write`, `users:read`, `users:write`, etc.
|
||||
- Plugin routes: `dms:read`, `dms:write`, `dms:delete`, `dms:share`, `calendar:read`, `calendar:write`, etc.
|
||||
- User preferences: `user_preferences:read`, `user_preferences:write` (added in Task 5.2)
|
||||
- Admin role (`*:*` wildcard) has access to all endpoints
|
||||
- Editor and viewer roles have scoped permissions per module
|
||||
|
||||
## Conclusion
|
||||
|
||||
All 158 UI functions across 24 categories have corresponding API endpoints. No missing endpoints
|
||||
were identified. The User Preferences API (Task 5.2) fills the previously missing UI state
|
||||
persistence gap (sidebar, theme, locale, active tab, sort preferences).
|
||||
@@ -0,0 +1,298 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the API client
|
||||
const mockApiGet = vi.fn();
|
||||
const mockApiPost = vi.fn();
|
||||
const mockApiPatch = vi.fn();
|
||||
const mockApiDelete = vi.fn();
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiGet: (...args: any[]) => mockApiGet(...args),
|
||||
apiPost: (...args: any[]) => mockApiPost(...args),
|
||||
apiPatch: (...args: any[]) => mockApiPatch(...args),
|
||||
apiDelete: (...args: any[]) => mockApiDelete(...args),
|
||||
}));
|
||||
|
||||
// Mock react-query
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
}),
|
||||
useQuery: vi.fn(),
|
||||
useMutation: vi.fn(({ mutationFn, onSuccess }) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
const result = await mutationFn(args);
|
||||
if (onSuccess) onSuccess(result, args);
|
||||
return result;
|
||||
},
|
||||
isPending: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Workflow API Hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useWorkflows', () => {
|
||||
it('calls apiGet with correct endpoint', async () => {
|
||||
const { useWorkflows } = await import('../workflows');
|
||||
const { useQuery } = await import('@tanstack/react-query');
|
||||
|
||||
(useQuery as any).mockImplementation(({ queryKey, queryFn }: any) => {
|
||||
expect(queryKey[0]).toBe('workflows');
|
||||
queryFn();
|
||||
return { data: undefined, isLoading: false };
|
||||
});
|
||||
|
||||
useWorkflows();
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/workflows?')
|
||||
);
|
||||
});
|
||||
|
||||
it('passes is_active filter when provided', async () => {
|
||||
const { useWorkflows } = await import('../workflows');
|
||||
const { useQuery } = await import('@tanstack/react-query');
|
||||
|
||||
(useQuery as any).mockImplementation(({ queryFn }: any) => {
|
||||
queryFn();
|
||||
return { data: undefined, isLoading: false };
|
||||
});
|
||||
|
||||
useWorkflows(1, 20, true);
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
expect.stringContaining('is_active=true')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWorkflow', () => {
|
||||
it('calls apiGet with correct endpoint', async () => {
|
||||
const { useWorkflow } = await import('../workflows');
|
||||
const { useQuery } = await import('@tanstack/react-query');
|
||||
|
||||
(useQuery as any).mockImplementation(({ queryKey, queryFn, enabled }: any) => {
|
||||
expect(queryKey).toEqual(['workflows', 'wf-123']);
|
||||
expect(enabled).toBe(true);
|
||||
queryFn();
|
||||
return { data: undefined, isLoading: false };
|
||||
});
|
||||
|
||||
useWorkflow('wf-123');
|
||||
expect(mockApiGet).toHaveBeenCalledWith('/workflows/wf-123');
|
||||
});
|
||||
|
||||
it('is disabled when id is undefined', async () => {
|
||||
const { useWorkflow } = await import('../workflows');
|
||||
const { useQuery } = await import('@tanstack/react-query');
|
||||
|
||||
(useQuery as any).mockImplementation(({ enabled }: any) => {
|
||||
expect(enabled).toBe(false);
|
||||
return { data: undefined, isLoading: false };
|
||||
});
|
||||
|
||||
useWorkflow(undefined);
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateWorkflow', () => {
|
||||
it('calls apiPost with correct endpoint and data', async () => {
|
||||
const { useCreateWorkflow } = await import('../workflows');
|
||||
const { useMutation } = await import('@tanstack/react-query');
|
||||
|
||||
(useMutation as any).mockImplementation(({ mutationFn, onSuccess }: any) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
const result = await mutationFn(args);
|
||||
if (onSuccess) onSuccess(result, args);
|
||||
return result;
|
||||
},
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const { mutateAsync } = useCreateWorkflow();
|
||||
const payload = {
|
||||
name: 'Test WF',
|
||||
steps: [{ name: 'S1', type: 'action' as const, config: {} }],
|
||||
};
|
||||
mockApiPost.mockResolvedValue({ id: 'wf-1', ...payload });
|
||||
await mutateAsync(payload);
|
||||
expect(mockApiPost).toHaveBeenCalledWith('/workflows', payload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateWorkflow', () => {
|
||||
it('calls apiPatch with correct endpoint and data', async () => {
|
||||
const { useUpdateWorkflow } = await import('../workflows');
|
||||
const { useMutation } = await import('@tanstack/react-query');
|
||||
|
||||
(useMutation as any).mockImplementation(({ mutationFn, onSuccess }: any) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
const result = await mutationFn(args);
|
||||
if (onSuccess) onSuccess(result, args);
|
||||
return result;
|
||||
},
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const { mutateAsync } = useUpdateWorkflow();
|
||||
const payload = { id: 'wf-1', data: { name: 'Updated' } };
|
||||
mockApiPatch.mockResolvedValue({ id: 'wf-1', name: 'Updated' });
|
||||
await mutateAsync(payload);
|
||||
expect(mockApiPatch).toHaveBeenCalledWith('/workflows/wf-1', {
|
||||
name: 'Updated',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteWorkflow', () => {
|
||||
it('calls apiDelete with correct endpoint', async () => {
|
||||
const { useDeleteWorkflow } = await import('../workflows');
|
||||
const { useMutation } = await import('@tanstack/react-query');
|
||||
|
||||
(useMutation as any).mockImplementation(({ mutationFn, onSuccess }: any) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
const result = await mutationFn(args);
|
||||
if (onSuccess) onSuccess(result, args);
|
||||
return result;
|
||||
},
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const { mutateAsync } = useDeleteWorkflow();
|
||||
mockApiDelete.mockResolvedValue(undefined);
|
||||
await mutateAsync('wf-1');
|
||||
expect(mockApiDelete).toHaveBeenCalledWith('/workflows/wf-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWorkflowInstances', () => {
|
||||
it('calls apiGet with instances endpoint', async () => {
|
||||
const { useWorkflowInstances } = await import('../workflows');
|
||||
const { useQuery } = await import('@tanstack/react-query');
|
||||
|
||||
(useQuery as any).mockImplementation(({ queryFn }: any) => {
|
||||
queryFn();
|
||||
return { data: undefined, isLoading: false };
|
||||
});
|
||||
|
||||
useWorkflowInstances();
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/workflows/instances?')
|
||||
);
|
||||
});
|
||||
|
||||
it('passes status filter when provided', async () => {
|
||||
const { useWorkflowInstances } = await import('../workflows');
|
||||
const { useQuery } = await import('@tanstack/react-query');
|
||||
|
||||
(useQuery as any).mockImplementation(({ queryFn }: any) => {
|
||||
queryFn();
|
||||
return { data: undefined, isLoading: false };
|
||||
});
|
||||
|
||||
useWorkflowInstances(1, 20, 'completed');
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
expect.stringContaining('status=completed')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWorkflowInstance', () => {
|
||||
it('calls apiGet with instance detail endpoint', async () => {
|
||||
const { useWorkflowInstance } = await import('../workflows');
|
||||
const { useQuery } = await import('@tanstack/react-query');
|
||||
|
||||
(useQuery as any).mockImplementation(({ queryFn, enabled }: any) => {
|
||||
expect(enabled).toBe(true);
|
||||
queryFn();
|
||||
return { data: undefined, isLoading: false };
|
||||
});
|
||||
|
||||
useWorkflowInstance('inst-1');
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
'/workflows/instances/inst-1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateWorkflowInstance', () => {
|
||||
it('calls apiPost with correct endpoint and data', async () => {
|
||||
const { useCreateWorkflowInstance } = await import('../workflows');
|
||||
const { useMutation } = await import('@tanstack/react-query');
|
||||
|
||||
(useMutation as any).mockImplementation(({ mutationFn, onSuccess }: any) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
const result = await mutationFn(args);
|
||||
if (onSuccess) onSuccess(result, args);
|
||||
return result;
|
||||
},
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const { mutateAsync } = useCreateWorkflowInstance();
|
||||
const payload = {
|
||||
workflowId: 'wf-1',
|
||||
data: { context: { foo: 'bar' } },
|
||||
};
|
||||
mockApiPost.mockResolvedValue({ id: 'inst-1' });
|
||||
await mutateAsync(payload);
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
'/workflows/wf-1/instances',
|
||||
{ context: { foo: 'bar' } }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAdvanceWorkflowInstance', () => {
|
||||
it('calls apiPost with advance endpoint and decision', async () => {
|
||||
const { useAdvanceWorkflowInstance } = await import('../workflows');
|
||||
const { useMutation } = await import('@tanstack/react-query');
|
||||
|
||||
(useMutation as any).mockImplementation(({ mutationFn, onSuccess }: any) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
const result = await mutationFn(args);
|
||||
if (onSuccess) onSuccess(result, args);
|
||||
return result;
|
||||
},
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const { mutateAsync } = useAdvanceWorkflowInstance();
|
||||
const payload = {
|
||||
instanceId: 'inst-1',
|
||||
data: { decision: 'approve' as const, comment: 'LGTM' },
|
||||
};
|
||||
mockApiPost.mockResolvedValue({ id: 'inst-1', status: 'in_progress' });
|
||||
await mutateAsync(payload);
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
'/workflows/instances/inst-1/advance',
|
||||
{ decision: 'approve', comment: 'LGTM' }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCancelWorkflowInstance', () => {
|
||||
it('calls apiPost with cancel endpoint', async () => {
|
||||
const { useCancelWorkflowInstance } = await import('../workflows');
|
||||
const { useMutation } = await import('@tanstack/react-query');
|
||||
|
||||
(useMutation as any).mockImplementation(({ mutationFn, onSuccess }: any) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
const result = await mutationFn(args);
|
||||
if (onSuccess) onSuccess(result, args);
|
||||
return result;
|
||||
},
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const { mutateAsync } = useCancelWorkflowInstance();
|
||||
mockApiPost.mockResolvedValue({ id: 'inst-1', status: 'cancelled' });
|
||||
await mutateAsync('inst-1');
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
'/workflows/instances/inst-1/cancel'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<PreferenceListResponse>('/user/preferences'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserPreference(key: string) {
|
||||
return useQuery({
|
||||
queryKey: ['userPreferences', key],
|
||||
queryFn: () =>
|
||||
apiGet<PreferenceEntry>(`/user/preferences/${key}`),
|
||||
enabled: !!key,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpsertUserPreference() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
key: string;
|
||||
value: unknown;
|
||||
}) =>
|
||||
apiPut<PreferenceEntry>(`/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'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Workflow API module — TypeScript types and React Query hooks.
|
||||
* Matches backend endpoints from /api/v1/workflows.
|
||||
*
|
||||
* Backend: app/routes/workflows.py, app/models/workflow.py, app/schemas/workflow.py
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface WorkflowStep {
|
||||
name: string;
|
||||
type: 'action' | 'approval' | 'notification' | 'condition';
|
||||
config: Record<string, unknown>;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
trigger_event?: string | null;
|
||||
steps: WorkflowStep[];
|
||||
is_active: boolean;
|
||||
created_by?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowListResponse extends PaginatedResponse<Workflow> {}
|
||||
|
||||
export interface WorkflowCreateInput {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
trigger_event?: string | null;
|
||||
steps: WorkflowStep[];
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowUpdateInput {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
trigger_event?: string | null;
|
||||
steps?: WorkflowStep[];
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export type InstanceStatus =
|
||||
| 'pending'
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'rejected'
|
||||
| 'cancelled';
|
||||
|
||||
export interface WorkflowInstance {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
status: InstanceStatus;
|
||||
current_step_index: number;
|
||||
context: Record<string, unknown>;
|
||||
initiated_by?: string | null;
|
||||
completed_at?: string | null;
|
||||
timeout_hours?: number | null;
|
||||
timeout_at?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface StepHistoryEntry {
|
||||
id: string;
|
||||
instance_id: string;
|
||||
step_index: number;
|
||||
step_type: string;
|
||||
action: string;
|
||||
actor_id?: string | null;
|
||||
details?: Record<string, unknown> | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowInstanceDetail extends WorkflowInstance {
|
||||
history: StepHistoryEntry[];
|
||||
workflow_name?: string | null;
|
||||
}
|
||||
|
||||
export interface InstanceListResponse extends PaginatedResponse<WorkflowInstance> {}
|
||||
|
||||
export interface InstanceCreateInput {
|
||||
context?: Record<string, unknown>;
|
||||
timeout_hours?: number | null;
|
||||
}
|
||||
|
||||
export interface AdvanceRequest {
|
||||
decision: 'approve' | 'reject';
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
// ── Workflow Definition Hooks ──
|
||||
|
||||
export function useWorkflows(page = 1, pageSize = 20, isActive?: boolean) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
});
|
||||
if (isActive !== undefined) params.set('is_active', String(isActive));
|
||||
return useQuery({
|
||||
queryKey: ['workflows', page, pageSize, isActive],
|
||||
queryFn: () =>
|
||||
apiGet<WorkflowListResponse>(`/workflows?${params.toString()}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkflow(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['workflows', id],
|
||||
queryFn: () => apiGet<Workflow>(`/workflows/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWorkflow() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: WorkflowCreateInput) =>
|
||||
apiPost<Workflow>('/workflows', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workflows'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWorkflow() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
data: WorkflowUpdateInput;
|
||||
}) => apiPatch<Workflow>(`/workflows/${id}`, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workflows'] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['workflows', variables.id],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWorkflow() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/workflows/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workflows'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Workflow Instance Hooks ──
|
||||
|
||||
export function useWorkflowInstances(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
statusFilter?: InstanceStatus
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
});
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
return useQuery({
|
||||
queryKey: ['workflowInstances', page, pageSize, statusFilter],
|
||||
queryFn: () =>
|
||||
apiGet<InstanceListResponse>(
|
||||
`/workflows/instances?${params.toString()}`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkflowInstance(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['workflowInstances', id],
|
||||
queryFn: () =>
|
||||
apiGet<WorkflowInstanceDetail>(`/workflows/instances/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWorkflowInstance() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
workflowId,
|
||||
data,
|
||||
}: {
|
||||
workflowId: string;
|
||||
data: InstanceCreateInput;
|
||||
}) =>
|
||||
apiPost<WorkflowInstance>(
|
||||
`/workflows/${workflowId}/instances`,
|
||||
data
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workflowInstances'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdvanceWorkflowInstance() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
instanceId,
|
||||
data,
|
||||
}: {
|
||||
instanceId: string;
|
||||
data: AdvanceRequest;
|
||||
}) =>
|
||||
apiPost<WorkflowInstance>(
|
||||
`/workflows/instances/${instanceId}/advance`,
|
||||
data
|
||||
),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workflowInstances'] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['workflowInstances', variables.instanceId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelWorkflowInstance() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (instanceId: string) =>
|
||||
apiPost<WorkflowInstance>(
|
||||
`/workflows/instances/${instanceId}/cancel`
|
||||
),
|
||||
onSuccess: (_data, instanceId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workflowInstances'] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['workflowInstances', instanceId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+5
-2
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Test for API Audit (Task 5.1) — verifies audit document exists and key endpoints are reachable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
|
||||
AUDIT_FILE = Path(__file__).parent.parent / "docs" / "api-audit.md"
|
||||
|
||||
|
||||
def test_api_audit_document_exists():
|
||||
"""Audit document docs/api-audit.md exists and has content."""
|
||||
assert AUDIT_FILE.exists(), f"API audit document not found at {AUDIT_FILE}"
|
||||
content = AUDIT_FILE.read_text()
|
||||
assert len(content) > 1000, "API audit document is too short"
|
||||
assert "# API Audit" in content, "Missing title"
|
||||
assert "Summary" in content, "Missing summary section"
|
||||
assert "Missing Endpoints" in content, "Missing missing endpoints section"
|
||||
|
||||
|
||||
def test_api_audit_covers_all_categories():
|
||||
"""Audit document covers all major UI categories."""
|
||||
content = AUDIT_FILE.read_text()
|
||||
required_categories = [
|
||||
"Contacts",
|
||||
"Calendar",
|
||||
"DMS",
|
||||
"Mail",
|
||||
"Notifications",
|
||||
"Workflows",
|
||||
"Automation",
|
||||
"AI Assistant",
|
||||
"AI Proactive",
|
||||
"Communication",
|
||||
"Unified Search",
|
||||
"Plugins",
|
||||
"Settings",
|
||||
"UI State",
|
||||
]
|
||||
for category in required_categories:
|
||||
assert category in content, f"Missing category '{category}' in audit document"
|
||||
|
||||
|
||||
def test_api_audit_covers_user_preferences():
|
||||
"""Audit document covers the user preferences API (Task 5.2)."""
|
||||
content = AUDIT_FILE.read_text()
|
||||
assert "user/preferences" in content, "Missing user preferences endpoint in audit"
|
||||
assert "sidebar" in content.lower(), "Missing sidebar state in audit"
|
||||
assert "theme" in content.lower(), "Missing theme in audit"
|
||||
assert "active_tab" in content or "active tab" in content.lower(), "Missing active tab in audit"
|
||||
|
||||
|
||||
def test_api_audit_covers_workflows():
|
||||
"""Audit document covers the workflow API (Task 5.3)."""
|
||||
content = AUDIT_FILE.read_text()
|
||||
assert "/api/v1/workflows" in content, "Missing workflow endpoint in audit"
|
||||
assert "instances" in content, "Missing workflow instances in audit"
|
||||
assert "advance" in content, "Missing workflow advance in audit"
|
||||
|
||||
|
||||
def test_api_audit_no_missing_endpoints():
|
||||
"""Audit document reports zero missing endpoints."""
|
||||
content = AUDIT_FILE.read_text()
|
||||
# Check that the summary shows 0 missing
|
||||
assert "| 0 |" in content or "Missing Endpoints — None" in content, \
|
||||
"Audit should report no missing endpoints"
|
||||
|
||||
|
||||
def test_api_audit_covers_rbac():
|
||||
"""Audit document covers RBAC enforcement."""
|
||||
content = AUDIT_FILE.read_text()
|
||||
assert "RBAC" in content, "Missing RBAC section in audit"
|
||||
assert "require_permission" in content, "Missing require_permission mention in audit"
|
||||
|
||||
|
||||
def test_api_audit_covers_frontend_modules():
|
||||
"""Audit document maps frontend API modules to backend routes."""
|
||||
content = AUDIT_FILE.read_text()
|
||||
assert "Frontend API Module Coverage" in content, "Missing frontend module coverage section"
|
||||
assert "api/workflows.ts" in content, "Missing workflows.ts in frontend coverage"
|
||||
assert "api/userPreferences.ts" in content, "Missing userPreferences.ts in frontend coverage"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_preferences_endpoint_reachable(client: AsyncClient, db_session):
|
||||
"""Verify the user preferences API endpoint (from Task 5.2) is reachable — covers sidebar/tab/filter state."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.get("/api/v1/user/preferences", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert "preferences" in resp.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_endpoints_reachable(client: AsyncClient, db_session):
|
||||
"""Verify the workflow API endpoints (from Task 5.3) are reachable."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.get("/api/v1/workflows", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert "items" in resp.json()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user