diff --git a/alembic/versions/0029_saved_filters.py b/alembic/versions/0029_saved_filters.py new file mode 100644 index 0000000..5290d40 --- /dev/null +++ b/alembic/versions/0029_saved_filters.py @@ -0,0 +1,40 @@ +"""saved_filters table + +Revision ID: 0029_saved_filters +Revises: 0028_user_preferences +Create Date: 2025-07-23 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, JSONB + +# revision identifiers +revision = "0029_saved_filters" +down_revision = "0028_user_preferences" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "saved_filters", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("tenant_id", UUID(as_uuid=True), nullable=False), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("entity_type", sa.String(50), nullable=False), + sa.Column("filter_criteria", 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", "entity_type", "name", name="uq_saved_filters_tenant_user_entity_name"), + ) + op.create_index("ix_saved_filters_tenant_user", "saved_filters", ["tenant_id", "user_id"]) + op.create_index("ix_saved_filters_tenant_entity", "saved_filters", ["tenant_id", "entity_type"]) + + +def downgrade() -> None: + op.drop_index("ix_saved_filters_tenant_entity", table_name="saved_filters") + op.drop_index("ix_saved_filters_tenant_user", table_name="saved_filters") + op.drop_table("saved_filters") diff --git a/app/main.py b/app/main.py index 1c7329c..9a874bd 100644 --- a/app/main.py +++ b/app/main.py @@ -48,6 +48,7 @@ from app.routes import ( system_settings, attachments, custom_fields, + saved_filters, ) @@ -306,6 +307,7 @@ def create_app() -> FastAPI: app.include_router(addresses.router) app.include_router(audit.router) app.include_router(custom_fields.router) + app.include_router(saved_filters.router) # ── Register plugin routes (before SPA catch-all) ────────────────── registry = get_registry() diff --git a/app/models/saved_filter.py b/app/models/saved_filter.py new file mode 100644 index 0000000..0859be0 --- /dev/null +++ b/app/models/saved_filter.py @@ -0,0 +1,36 @@ +"""SavedFilter model — tenant-scoped, user-scoped saved filter criteria.""" + +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 SavedFilter(Base, TenantMixin): + """Saved filter — reusable filter criteria for list views (contacts, mail, calendar, DMS).""" + + __tablename__ = "saved_filters" + __table_args__ = ( + UniqueConstraint("tenant_id", "user_id", "entity_type", "name", name="uq_saved_filters_tenant_user_entity_name"), + Index("ix_saved_filters_tenant_user", "tenant_id", "user_id"), + Index("ix_saved_filters_tenant_entity", "tenant_id", "entity_type"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + entity_type: Mapped[str] = mapped_column( + String(50), nullable=False + ) # contacts, mail, calendar, dms + filter_criteria: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) + user_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) diff --git a/app/routes/saved_filters.py b/app/routes/saved_filters.py new file mode 100644 index 0000000..9809786 --- /dev/null +++ b/app/routes/saved_filters.py @@ -0,0 +1,136 @@ +"""Saved filters routes — CRUD for reusable filter criteria.""" + +from __future__ import annotations + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_db +from app.deps import get_current_user, require_permission +from app.models.saved_filter import SavedFilter + +router = APIRouter(prefix="/api/v1/saved-filters", tags=["saved-filters"]) + +VALID_ENTITY_TYPES = {"contacts", "mail", "calendar", "dms"} + + +class SavedFilterCreate(BaseModel): + """Schema for creating a saved filter.""" + name: str = Field(..., min_length=1, max_length=100) + entity_type: str = Field(..., pattern="^(contacts|mail|calendar|dms)$") + filter_criteria: dict[str, Any] = Field(default_factory=dict) + + +class SavedFilterUpdate(BaseModel): + """Schema for updating a saved filter.""" + name: str | None = Field(default=None, min_length=1, max_length=100) + filter_criteria: dict[str, Any] | None = None + + +def _filter_to_dict(f: SavedFilter) -> dict[str, Any]: + return { + "id": str(f.id), + "name": f.name, + "entity_type": f.entity_type, + "filter_criteria": f.filter_criteria, + "user_id": str(f.user_id), + "created_at": f.created_at.isoformat() if f.created_at else None, + "updated_at": f.updated_at.isoformat() if f.updated_at else None, + } + + +@router.get("", dependencies=[Depends(require_permission("contacts:read"))]) +async def list_saved_filters( + entity_type: str | None = Query(None, pattern="^(contacts|mail|calendar|dms)$"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """List saved filters for the current user, optionally filtered by entity_type.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + query = select(SavedFilter).where( + SavedFilter.tenant_id == tenant_id, + SavedFilter.user_id == user_id, + SavedFilter.deleted_at.is_(None), + ) + if entity_type: + query = query.where(SavedFilter.entity_type == entity_type) + query = query.order_by(SavedFilter.name) + + result = await db.execute(query) + filters = result.scalars().all() + return [_filter_to_dict(f) for f in filters] + + +@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("contacts:read"))]) +async def create_saved_filter( + body: SavedFilterCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Create a new saved filter for the current user.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + # Check uniqueness within user+entity + existing = await db.execute( + select(SavedFilter).where( + SavedFilter.tenant_id == tenant_id, + SavedFilter.user_id == user_id, + SavedFilter.entity_type == body.entity_type, + SavedFilter.name == body.name, + SavedFilter.deleted_at.is_(None), + ) + ) + if existing.scalar_one_or_none() is not None: + raise HTTPException(409, detail={"detail": "Filter name already exists", "code": "duplicate"}) + + saved = SavedFilter( + tenant_id=tenant_id, + user_id=user_id, + name=body.name, + entity_type=body.entity_type, + filter_criteria=body.filter_criteria, + ) + db.add(saved) + await db.flush() + return _filter_to_dict(saved) + + +@router.delete("/{filter_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("contacts:read"))]) +async def delete_saved_filter( + filter_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Delete a saved filter (soft-delete).""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + try: + fid = uuid.UUID(filter_id) + except (ValueError, TypeError): + raise HTTPException(400, detail={"detail": "Invalid filter_id", "code": "invalid_id"}) from None + + result = await db.execute( + select(SavedFilter).where( + SavedFilter.id == fid, + SavedFilter.tenant_id == tenant_id, + SavedFilter.user_id == user_id, + SavedFilter.deleted_at.is_(None), + ) + ) + saved = result.scalar_one_or_none() + if saved is None: + raise HTTPException(404, detail={"detail": "Saved filter not found", "code": "not_found"}) + + from datetime import datetime, timezone + saved.deleted_at = datetime.now(timezone.utc) + await db.flush() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/frontend/src/__tests__/SavedFilters.test.tsx b/frontend/src/__tests__/SavedFilters.test.tsx new file mode 100644 index 0000000..dedc5a2 --- /dev/null +++ b/frontend/src/__tests__/SavedFilters.test.tsx @@ -0,0 +1,114 @@ +/** + * SavedFilters component tests. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SavedFilters } from '@/components/SavedFilters'; + +// Mock i18n +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// Mock toast +vi.mock('@/components/ui/Toast', () => ({ + useToast: () => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }), +})); + +// Mock UI components +vi.mock('@/components/ui/Input', () => ({ + Input: ({ value, onChange, label, ...props }: any) => ( +