diff --git a/alembic/versions/0047_saved_views.py b/alembic/versions/0047_saved_views.py new file mode 100644 index 0000000..1790bdc --- /dev/null +++ b/alembic/versions/0047_saved_views.py @@ -0,0 +1,39 @@ +"""Create saved_views table + +Revision ID: 0047_saved_views +Revises: 0046_plugin_allowlist +Create Date: 2026-07-28 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, JSONB + +revision = "0047_saved_views" +down_revision = "0046_plugin_allowlist" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "saved_views", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("entity_type", sa.String(50), nullable=False), + sa.Column("view_config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("tenant_id", UUID(as_uuid=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_unique_constraint("uq_saved_views_tenant_user_entity_name", "saved_views", ["tenant_id", "user_id", "entity_type", "name"]) + op.create_index("ix_saved_views_tenant_user", "saved_views", ["tenant_id", "user_id"]) + op.create_index("ix_saved_views_tenant_entity", "saved_views", ["tenant_id", "entity_type"]) + + +def downgrade() -> None: + op.drop_index("ix_saved_views_tenant_entity", table_name="saved_views") + op.drop_index("ix_saved_views_tenant_user", table_name="saved_views") + op.drop_unique_constraint("uq_saved_views_tenant_user_entity_name", "saved_views") + op.drop_table("saved_views") diff --git a/app/main.py b/app/main.py index 4397f80..f846fb9 100644 --- a/app/main.py +++ b/app/main.py @@ -56,6 +56,7 @@ from app.routes import ( custom_field_definitions, custom_fields, saved_filters, + saved_views, webhooks, backups, ) @@ -394,6 +395,7 @@ def create_app() -> FastAPI: app.include_router(custom_field_definitions.router) app.include_router(custom_fields.router) app.include_router(saved_filters.router) + app.include_router(saved_views.router) app.include_router(webhooks.router) app.include_router(errors.router) diff --git a/app/models/__init__.py b/app/models/__init__.py index c5ac6d6..6a4ea33 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -25,6 +25,7 @@ from app.models.backup import Backup from app.models.custom_field_definition import CustomFieldDefinition from app.models.webhook import Webhook from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory +from app.models.saved_view import SavedView __all__ = [ "Tenant", @@ -63,4 +64,5 @@ __all__ = [ "Workflow", "WorkflowInstance", "WorkflowStepHistory", + "SavedView", ] diff --git a/app/models/saved_view.py b/app/models/saved_view.py new file mode 100644 index 0000000..60d4d82 --- /dev/null +++ b/app/models/saved_view.py @@ -0,0 +1,36 @@ +"""SavedView model — tenant-scoped, user-scoped saved view configurations.""" + +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 SavedView(Base, TenantMixin): + """Saved view — reusable view configuration (filter+sort+group+viewMode+folder) for list views.""" + + __tablename__ = "saved_views" + __table_args__ = ( + UniqueConstraint("tenant_id", "user_id", "entity_type", "name", name="uq_saved_views_tenant_user_entity_name"), + Index("ix_saved_views_tenant_user", "tenant_id", "user_id"), + Index("ix_saved_views_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 + view_config: 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_views.py b/app/routes/saved_views.py new file mode 100644 index 0000000..6adc241 --- /dev/null +++ b/app/routes/saved_views.py @@ -0,0 +1,172 @@ +"""Saved views routes — CRUD for reusable view configurations.""" + +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_view import SavedView + +router = APIRouter(prefix="/api/v1/saved-views", tags=["saved-views"]) + +VALID_ENTITY_TYPES = {"contacts", "mail", "calendar", "dms"} + + +class SavedViewCreate(BaseModel): + """Schema for creating a saved view.""" + name: str = Field(..., min_length=1, max_length=100) + entity_type: str = Field(..., pattern="^(contacts|mail|calendar|dms)$") + view_config: dict[str, Any] = Field(default_factory=dict) + + +class SavedViewUpdate(BaseModel): + """Schema for updating a saved view.""" + name: str | None = Field(default=None, min_length=1, max_length=100) + view_config: dict[str, Any] | None = None + + +def _view_to_dict(v: SavedView) -> dict[str, Any]: + return { + "id": str(v.id), + "name": v.name, + "entity_type": v.entity_type, + "view_config": v.view_config, + "user_id": str(v.user_id), + "created_at": v.created_at.isoformat() if v.created_at else None, + "updated_at": v.updated_at.isoformat() if v.updated_at else None, + } + + +@router.get("", dependencies=[Depends(require_permission("contacts:read"))]) +async def list_saved_views( + 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 views 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(SavedView).where( + SavedView.tenant_id == tenant_id, + SavedView.user_id == user_id, + SavedView.deleted_at.is_(None), + ) + if entity_type: + query = query.where(SavedView.entity_type == entity_type) + query = query.order_by(SavedView.name) + + result = await db.execute(query) + views = result.scalars().all() + return [_view_to_dict(v) for v in views] + + +@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("contacts:read"))]) +async def create_saved_view( + body: SavedViewCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Create a new saved view 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(SavedView).where( + SavedView.tenant_id == tenant_id, + SavedView.user_id == user_id, + SavedView.entity_type == body.entity_type, + SavedView.name == body.name, + SavedView.deleted_at.is_(None), + ) + ) + if existing.scalar_one_or_none() is not None: + raise HTTPException(409, detail={"detail": "View name already exists", "code": "duplicate"}) + + saved = SavedView( + tenant_id=tenant_id, + user_id=user_id, + name=body.name, + entity_type=body.entity_type, + view_config=body.view_config, + ) + db.add(saved) + await db.flush() + return _view_to_dict(saved) + + +@router.put("/{view_id}", dependencies=[Depends(require_permission("contacts:read"))]) +async def update_saved_view( + view_id: str, + body: SavedViewUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update a saved view.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + try: + vid = uuid.UUID(view_id) + except (ValueError, TypeError): + raise HTTPException(400, detail={"detail": "Invalid view_id", "code": "invalid_id"}) from None + + result = await db.execute( + select(SavedView).where( + SavedView.id == vid, + SavedView.tenant_id == tenant_id, + SavedView.user_id == user_id, + SavedView.deleted_at.is_(None), + ) + ) + saved = result.scalar_one_or_none() + if saved is None: + raise HTTPException(404, detail={"detail": "Saved view not found", "code": "not_found"}) + + if body.name is not None: + saved.name = body.name + if body.view_config is not None: + saved.view_config = body.view_config + await db.flush() + return _view_to_dict(saved) + + +@router.delete("/{view_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("contacts:read"))]) +async def delete_saved_view( + view_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Delete a saved view (soft-delete).""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + try: + vid = uuid.UUID(view_id) + except (ValueError, TypeError): + raise HTTPException(400, detail={"detail": "Invalid view_id", "code": "invalid_id"}) from None + + result = await db.execute( + select(SavedView).where( + SavedView.id == vid, + SavedView.tenant_id == tenant_id, + SavedView.user_id == user_id, + SavedView.deleted_at.is_(None), + ) + ) + saved = result.scalar_one_or_none() + if saved is None: + raise HTTPException(404, detail={"detail": "Saved view 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/api/savedViews.ts b/frontend/src/api/savedViews.ts new file mode 100644 index 0000000..5b6715f --- /dev/null +++ b/frontend/src/api/savedViews.ts @@ -0,0 +1,52 @@ +/** + * Saved Views API hooks — CRUD for reusable view configurations. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost, apiPut, apiDelete } from './client'; + +export interface SavedView { + id: string; + name: string; + entity_type: string; + view_config: Record; + user_id: string; + created_at?: string; + updated_at?: string; +} + +export function useSavedViews(entityType?: string) { + const params = new URLSearchParams(); + if (entityType) params.set('entity_type', entityType); + const qs = params.toString() ? `?${params.toString()}` : ''; + return useQuery({ + queryKey: ['saved-views', entityType], + queryFn: () => apiGet(`/saved-views${qs}`), + }); +} + +export function useCreateSavedView() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string; entity_type: string; view_config: Record }) => + apiPost('/saved-views', data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-views'] }), + }); +} + +export function useUpdateSavedView() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: { name?: string; view_config?: Record } }) => + apiPut(`/saved-views/${id}`, data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-views'] }), + }); +} + +export function useDeleteSavedView() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/saved-views/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-views'] }), + }); +} diff --git a/frontend/src/pages/ContactsList.tsx b/frontend/src/pages/ContactsList.tsx index eeee9ee..b63fb2f 100644 --- a/frontend/src/pages/ContactsList.tsx +++ b/frontend/src/pages/ContactsList.tsx @@ -24,6 +24,7 @@ import { FilterPanel, applyFilters, emptyFilterState, type FilterState, type Sav import { SortPanel, applySorting, emptySortState, type SortState } from '@/components/contacts/SortPanel'; import { GroupPanel, applyGrouping, emptyGroupState, type GroupState, type GroupedContacts } from '@/components/contacts/GroupPanel'; import { SaveViewDialog, type SaveViewSelection } from '@/components/contacts/SaveViewDialog'; +import { useSavedViews, useCreateSavedView, useDeleteSavedView } from '@/api/savedViews'; import { useUnifiedContacts, useUnifiedContact, @@ -52,12 +53,17 @@ export function ContactsListPage() { const [viewsOpen, setViewsOpen] = useState(true); const [foldersOpen, setFoldersOpen] = useState(true); const [multiSelectFolders, setMultiSelectFolders] = useState([]); - const [savedViews, setSavedViews] = useState<{ id: string; name: string; filterState: FilterState; sortState: SortState; groupState: GroupState; selectedFilter: ContactFilter; viewMode: ContactViewMode; multiSelectFolders: string[]; selection: SaveViewSelection }[]>([]); const [activeViewId, setActiveViewId] = useState(null); const [savedFilters, setSavedFilters] = useState([]); const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false); const openWindow = useWindowStore((s) => s.openWindow); + // Saved views from API (durable, per-user) + const { data: apiSavedViews } = useSavedViews('contacts'); + const createViewMut = useCreateSavedView(); + const deleteViewMut = useDeleteSavedView(); + const savedViews = apiSavedViews ?? []; + // Debounce search useEffect(() => { const timer = setTimeout(() => setDebouncedSearch(search), 300); @@ -209,42 +215,43 @@ export function ContactsListPage() { setSaveViewDialogOpen(true); }, []); - // Actually save the view with selection + // Actually save the view with selection — via API const handleSaveViewConfirm = useCallback((name: string, selection: SaveViewSelection) => { - const id = `view-${Date.now()}`; - setSavedViews((prev) => [...prev, { - id, - name, - filterState: { ...filterState }, - sortState: { ...sortState }, - groupState: { ...groupState }, + const viewConfig = { + filterState, + sortState, + groupState, selectedFilter, viewMode, - multiSelectFolders: [...multiSelectFolders], + multiSelectFolders, selection, - }]); - setActiveViewId(id); - }, [filterState, sortState, groupState, selectedFilter, viewMode, multiSelectFolders]); + }; + createViewMut.mutate({ name, entity_type: 'contacts', view_config: viewConfig }, { + onSuccess: (data) => setActiveViewId(data.id), + }); + }, [filterState, sortState, groupState, selectedFilter, viewMode, multiSelectFolders, createViewMut]); - // Apply a saved custom view — only selected components - const applySavedView = useCallback((view: { id: string; filterState: FilterState; sortState: SortState; groupState: GroupState; selectedFilter: ContactFilter; viewMode: ContactViewMode; multiSelectFolders: string[]; selection: SaveViewSelection }) => { - if (view.selection.folder) { - setSelectedFilter(view.selectedFilter); - setMultiSelectFolders(view.multiSelectFolders || []); + // Apply a saved custom view — only selected components (from API view_config) + const applySavedView = useCallback((view: { id: string; view_config: any }) => { + const cfg = view.view_config; + if (!cfg) return; + if (cfg.selection?.folder) { + setSelectedFilter(cfg.selectedFilter); + setMultiSelectFolders(cfg.multiSelectFolders || []); } - if (view.selection.viewMode) setViewMode(view.viewMode); - if (view.selection.filter) setFilterState({ ...view.filterState }); - if (view.selection.group) setGroupState({ ...view.groupState }); - if (view.selection.sort) setSortState({ ...view.sortState }); + if (cfg.selection?.viewMode) setViewMode(cfg.viewMode); + if (cfg.selection?.filter) setFilterState({ ...cfg.filterState }); + if (cfg.selection?.group) setGroupState({ ...cfg.groupState }); + if (cfg.selection?.sort) setSortState({ ...cfg.sortState }); setActiveViewId(view.id); setPage(1); }, []); - // Delete a saved custom view + // Delete a saved custom view — via API const deleteSavedView = useCallback((id: string) => { - setSavedViews((prev) => prev.filter((v) => v.id !== id)); + deleteViewMut.mutate(id); if (activeViewId === id) setActiveViewId(null); - }, [activeViewId]); + }, [activeViewId, deleteViewMut]); // Save current filter configuration const handleSaveFilter = useCallback((name: string, filterState: FilterState) => {