4 Commits

Author SHA1 Message Date
Agent Zero a914280a4e docs: update PROGRESS.md with Phase 5 Batch 6a summary (Tasks 5.20-5.22) 2026-07-23 23:45:19 +02:00
Agent Zero 182af355d1 feat(5.22): Saved Searches / Smart Lists — reusable filters for list views
- SavedFilter model with TenantMixin (name, entity_type, filter_criteria JSONB, user_id)
- Routes: GET/POST /saved-filters, DELETE /saved-filters/{id} with RBAC
- Alembic migration 0029_saved_filters creates saved_filters table
- Frontend: SavedFilters.tsx component with save/load/delete UI
- Frontend: api/savedFilters.ts with React Query hooks
- Integrated into ContactsListPage as example
- i18n keys for savedFilters.* in de.json and en.json
- Tests: test_saved_filters.py (9 tests) + SavedFilters.test.tsx (3 tests)
- Registered SavedFilter in conftest.py
2026-07-23 23:44:50 +02:00
Agent Zero 2c9e74776e feat(5.21): Tasks Plugin — activities with status, priority, due dates, ARQ reminders
- New builtin plugin app/plugins/builtins/tasks/ with full CRUD
- Task model with TenantMixin (title, description, status, priority, due_date, assigned_to, contact_id)
- Routes: GET/POST /tasks, GET/PATCH/DELETE /tasks/{id}, POST /tasks/{id}/assign, POST /tasks/{id}/status
- All routes RBAC-protected (tasks:read, tasks:write, tasks:delete)
- ARQ cron job tasks_due_reminder (daily 8:00) sends notifications for due tasks
- Migration 0001_initial.sql creates tasks table with indexes
- Frontend: Tasks.tsx page with list, filter, create/edit modal, detail modal
- Frontend: api/tasks.ts with React Query hooks
- Route /tasks in routes/index.tsx, sidebar entry via plugin manifest
- i18n keys for nav.tasks and tasks.* in de.json and en.json
- Tests: test_tasks.py (11 tests) + Tasks.test.tsx (3 tests)
- Registered TasksPlugin in conftest.py and worker.py
2026-07-23 23:41:34 +02:00
Agent Zero bdad91a649 feat(5.20): Custom Fields — plugin-defined fields in Contact UI
- Add CustomFieldDefinition to PluginManifest (text/number/date/select/multiselect/boolean)
- Add GET/PATCH /api/v1/contacts/{id}/custom-fields routes
- Merge plugin definitions with stored values in contacts.custom JSONB
- Add CustomFieldRenderer component (read + edit modes)
- Integrate into ContactDetail (read-only) and ContactEditModal (editable)
- Add custom_fields to pluginStore and active manifests API
- Add useCustomFields/useUpdateCustomFields React Query hooks
- Tests: test_custom_fields.py (9 tests) + CustomFieldRenderer.test.tsx (5 tests)
- i18n keys already present (contacts.customFields)
2026-07-23 23:35:35 +02:00
41 changed files with 3041 additions and 6 deletions
+80
View File
@@ -464,3 +464,83 @@ LeoCRM-Agenten können externe MCP-Server nutzen (Web-Search, Code-Execution, ex
- Bestehende Patterns verwendet (apiClient, React Query hooks, lazy-loaded pages)
**Phase 5 Batch 5 Gesamt: ✅ Complete**
---
## Phase 5 Batch 6a (Tasks 5.20-5.22) — ✅ Complete
### Task 5.20: Custom Fields — Plugin-Felder in UI (6h)
**Backend:**
- `app/plugins/manifest.py` — `CustomFieldDefinition` model (name, label, label_key, field_type, options, default_value, required, entity) + `custom_fields` field on `PluginManifest`
- `app/routes/custom_fields.py` — `GET/PATCH /api/v1/contacts/{id}/custom-fields` routes
- Merges plugin-defined field definitions with stored values from `contacts.custom` JSONB
- Validates required fields, select/multiselect options
- RBAC: `contacts:read` / `contacts:write`
- `app/plugins/registry.py` — `get_active_manifests` now includes `custom_fields` in response
**Frontend:**
- `frontend/src/api/customFields.ts` — `useCustomFields`, `useUpdateCustomFields` React Query hooks
- `frontend/src/components/contacts/CustomFieldRenderer.tsx` — renders fields by type (text/number/date/select/multiselect/boolean) in read + edit modes
- Integrated into `ContactDetail` (read-only) and `ContactEditModal` (editable with save logic)
- `frontend/src/store/pluginStore.ts` — `PluginCustomFieldDefinition` interface + `getCustomFieldsForEntity` selector
**Tests:**
- `tests/test_custom_fields.py` — 9 tests (GET/PATCH/manifest validation)
- `frontend/src/__tests__/CustomFieldRenderer.test.tsx` — 5 tests (read/edit/multiselect/empty)
### Task 5.21: Tasks-Plugin (12h)
**Backend:**
- New plugin `app/plugins/builtins/tasks/` with full structure:
- `plugin.py` — PluginManifest (name='tasks', dependencies=['permissions'], permissions=['tasks:read/write/delete'])
- `models.py` — Task model with TenantMixin (title, description, status, priority, due_date, assigned_to, contact_id)
- `schemas.py` — Pydantic schemas for CRUD + assign + status
- `routes.py` — CRUD endpoints: GET/POST /tasks, GET/PATCH/DELETE /tasks/{id}, POST /tasks/{id}/assign, POST /tasks/{id}/status
- `services.py` — Business logic with filtering, pagination, soft-delete
- `migrations/0001_initial.sql` — Creates tasks table with indexes
- `jobs.py` — ARQ `tasks_due_reminder` cron job (daily 8:00) sends notifications for due tasks
- Registered in `app/core/worker.py` (functions + cron_jobs)
- Registered in `tests/conftest.py`
**Frontend:**
- `frontend/src/api/tasks.ts` — Full React Query hooks (useTasks, useTask, useCreateTask, useUpdateTask, useDeleteTask, useAssignTask, useUpdateTaskStatus)
- `frontend/src/pages/Tasks.tsx` — Task list with filter (status/priority/search), create/edit modal, detail modal, pagination
- Route `/tasks` in `routes/index.tsx` (lazy-loaded)
- Sidebar entry via plugin manifest menu_items
- i18n keys for `nav.tasks` and `tasks.*` in de.json and en.json
**Tests:**
- `tests/test_tasks.py` — 11 tests (list/create/update/status/delete + auth + validation)
- `frontend/src/__tests__/Tasks.test.tsx` — 3 tests (render/list/create modal)
### Task 5.22: Saved Searches / Smart Lists (6h)
**Backend:**
- `app/models/saved_filter.py` — SavedFilter model with TenantMixin (name, entity_type, filter_criteria JSONB, user_id)
- `app/routes/saved_filters.py` — GET/POST /saved-filters, DELETE /saved-filters/{id} with RBAC
- `alembic/versions/0029_saved_filters.py` — Migration creates saved_filters table
- Registered in `app/main.py` and `tests/conftest.py`
**Frontend:**
- `frontend/src/api/savedFilters.ts` — useSavedFilters, useCreateSavedFilter, useDeleteSavedFilter hooks
- `frontend/src/components/SavedFilters.tsx` — Filter-builder UI with save button, load saved filters as tabs, delete
- Integrated into `ContactsListPage` as example (saves search/type/sort criteria)
- i18n keys for `savedFilters.*` in de.json and en.json
**Tests:**
- `tests/test_saved_filters.py` — 9 tests (list/create/delete + auth + validation + duplicate)
- `frontend/src/__tests__/SavedFilters.test.tsx` — 3 tests (render/save modal/load filter)
### Verifikation
- TSC: 0 neue Errors (2 pre-existing Dms.tsx errors)
- 3 Commits mit klaren Messages
- Mindestens 3 Tests pro Task (9+11+9 backend, 5+3+3 frontend)
- RBAC (require_permission) auf allen API-Routes
- TenantMixin auf allen neuen DB-Models
- i18n (de.json, en.json) aktualisiert
- Keine .env committet
- Bestehende Patterns verwendet (apiClient, React Query hooks, lazy-loaded pages, Zustand stores)
- Plugins automatisch via pkgutil entdeckt
**Phase 5 Batch 6a Gesamt: ✅ Complete**
+40
View File
@@ -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")
+3
View File
@@ -53,6 +53,7 @@ from app.plugins.builtins.automation.scheduler import scheduler_tick
from app.plugins.builtins.automation.workflow_timeout import check_workflow_timeouts
from app.plugins.builtins.automation.agent_runner import run_agent
from app.plugins.builtins.automation.execution_engine import run_automation
from app.plugins.builtins.tasks.jobs import tasks_due_reminder
class WorkerSettings:
@@ -69,6 +70,7 @@ class WorkerSettings:
check_workflow_timeouts,
run_agent,
run_automation,
tasks_due_reminder,
]
redis_settings = _get_redis_settings()
on_startup = on_startup
@@ -79,4 +81,5 @@ class WorkerSettings:
cron_jobs = [
cron(scheduler_tick, second={0, 30}),
cron(check_workflow_timeouts, minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}),
cron(tasks_due_reminder, hour=8, minute=0),
]
+4
View File
@@ -47,6 +47,8 @@ from app.routes import (
sequences,
system_settings,
attachments,
custom_fields,
saved_filters,
)
@@ -304,6 +306,8 @@ def create_app() -> FastAPI:
app.include_router(attachments.router)
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()
+36
View File
@@ -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
)
+5
View File
@@ -0,0 +1,5 @@
"""Tasks builtin plugin."""
from app.plugins.builtins.tasks.plugin import TasksPlugin
__all__ = ["TasksPlugin"]
+51
View File
@@ -0,0 +1,51 @@
"""ARQ reminder job for due tasks — sends notifications for overdue/due tasks."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from app.core.db import get_session_factory
logger = logging.getLogger(__name__)
async def tasks_due_reminder(ctx: dict) -> None:
"""Check for due tasks and send notifications to assigned users.
Runs daily at 8:00 via cron. Finds all non-done tasks with due_date <= now
and creates a notification for the assigned user.
"""
from app.plugins.builtins.tasks.services import get_due_tasks
from app.models.tenant import Tenant
from app.core.notifications import create_notification
from sqlalchemy import select
factory = get_session_factory()
async with factory() as db:
result = await db.execute(select(Tenant))
tenants = result.scalars().all()
total_notified = 0
for tenant in tenants:
try:
due_tasks = await get_due_tasks(db, tenant.id)
for task in due_tasks:
if not task.get("assigned_to"):
continue
await create_notification(
db,
user_id=task["assigned_to"],
tenant_id=str(tenant.id),
type="task_due",
title=f"Task due: {task['title']}",
body=f"Task '{task['title']}' is due. Priority: {task['priority']}",
data={"task_id": task["id"], "due_date": task.get("due_date")},
)
total_notified += 1
except Exception:
logger.warning(f"Failed to process due tasks for tenant {tenant.id}", exc_info=True)
if total_notified > 0:
logger.info(f"Tasks reminder: sent {total_notified} notifications")
await db.commit()
@@ -0,0 +1,21 @@
-- Tasks plugin initial migration: creates tasks table
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'open',
priority VARCHAR(10) NOT NULL DEFAULT 'medium',
due_date TIMESTAMPTZ,
assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_deleted ON tasks(tenant_id, deleted_at);
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_status ON tasks(tenant_id, status);
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_assigned ON tasks(tenant_id, assigned_to);
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_due ON tasks(tenant_id, due_date);
CREATE INDEX IF NOT EXISTS ix_tasks_contact ON tasks(contact_id);
+49
View File
@@ -0,0 +1,49 @@
"""Task model for the Tasks plugin."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class Task(Base, TenantMixin):
"""Task entity — free activities (calls, notes, visits) linked to contacts."""
__tablename__ = "tasks"
__table_args__ = (
Index("ix_tasks_tenant_deleted", "tenant_id", "deleted_at"),
Index("ix_tasks_tenant_status", "tenant_id", "status"),
Index("ix_tasks_tenant_assigned", "tenant_id", "assigned_to"),
Index("ix_tasks_tenant_due", "tenant_id", "due_date"),
Index("ix_tasks_contact", "contact_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="open"
) # open, in_progress, done
priority: Mapped[str] = mapped_column(
String(10), nullable=False, default="medium"
) # low, medium, high, urgent
due_date: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
assigned_to: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
contact_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("contacts.id", ondelete="SET NULL"), nullable=True
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
+65
View File
@@ -0,0 +1,65 @@
"""Tasks plugin — manage free tasks/activities linked to contacts."""
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import (
PluginManifest,
PluginRouteDef,
FrontendMenuItem,
FrontendPageRoute,
CronJobContribution,
)
class TasksPlugin(BasePlugin):
"""Tasks plugin for managing activities (calls, notes, visits) linked to contacts."""
manifest = PluginManifest(
name="tasks",
version="1.0.0",
display_name="Tasks",
description="Manage free tasks/activities with status, priority, due dates, and contact links.",
dependencies=["permissions"],
routes=[
PluginRouteDef(
path="/api/v1/tasks",
module="app.plugins.builtins.tasks.routes",
router_attr="router",
),
],
events=[],
migrations=["0001_initial.sql"],
permissions=[
"tasks:read",
"tasks:write",
"tasks:delete",
],
is_core=True,
menu_items=[
FrontendMenuItem(
label_key="nav.tasks",
label="Tasks",
path="/tasks",
icon="CheckSquare",
order=30,
),
],
page_routes=[
FrontendPageRoute(
path="/tasks",
component="@/pages/Tasks",
protected=True,
order=30,
),
],
cron_jobs=[
CronJobContribution(
name="tasks_due_reminder",
cron_expression="0 8 * * *",
job_type="custom",
target_name="tasks_due_reminder",
plugin_name="tasks",
),
],
)
+145
View File
@@ -0,0 +1,145 @@
"""Tasks plugin routes — CRUD, assign, status update."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.plugins.builtins.tasks import services
from app.plugins.builtins.tasks.schemas import (
TaskAssignRequest,
TaskCreate,
TaskStatusRequest,
TaskUpdate,
)
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
@router.get("", dependencies=[Depends(require_permission("tasks:read"))])
async def list_tasks(
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=100),
status: str | None = Query(None, pattern="^(open|in_progress|done)$"),
priority: str | None = Query(None, pattern="^(low|medium|high|urgent)$"),
assigned_to: str | None = Query(None),
contact_id: str | None = Query(None),
search: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List tasks with filtering and pagination."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await services.list_tasks(
db, tenant_id,
page=page, page_size=page_size,
status=status, priority=priority,
assigned_to=assigned_to, contact_id=contact_id,
search=search,
)
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("tasks:write"))])
async def create_task(
body: TaskCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new task."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump()
return await services.create_task(db, tenant_id, user_id, data)
@router.get("/{task_id}", dependencies=[Depends(require_permission("tasks:read"))])
async def get_task(
task_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a single task by ID."""
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(task_id, "task_id")
result = await services.get_task(db, tenant_id, tid)
if result is None:
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
return result
@router.patch("/{task_id}", dependencies=[Depends(require_permission("tasks:write"))])
async def update_task(
task_id: str,
body: TaskUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a task."""
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(task_id, "task_id")
data = body.model_dump(exclude_unset=True)
result = await services.update_task(db, tenant_id, tid, data)
if result is None:
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
return result
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("tasks:delete"))])
async def delete_task(
task_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a task (soft-delete)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(task_id, "task_id")
deleted = await services.delete_task(db, tenant_id, tid)
if not deleted:
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/{task_id}/assign", dependencies=[Depends(require_permission("tasks:write"))])
async def assign_task(
task_id: str,
body: TaskAssignRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Assign a task to a user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(task_id, "task_id")
assigned_to = _parse_uuid(body.assigned_to, "assigned_to")
result = await services.assign_task(db, tenant_id, tid, assigned_to)
if result is None:
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
return result
@router.post("/{task_id}/status", dependencies=[Depends(require_permission("tasks:write"))])
async def update_task_status(
task_id: str,
body: TaskStatusRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update task status."""
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(task_id, "task_id")
result = await services.update_task_status(db, tenant_id, tid, body.status)
if result is None:
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
return result
+63
View File
@@ -0,0 +1,63 @@
"""Pydantic schemas for the Tasks plugin."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class TaskCreate(BaseModel):
"""Schema for creating a task."""
title: str = Field(..., min_length=1, max_length=255)
description: str | None = None
status: str = Field(default="open", pattern="^(open|in_progress|done)$")
priority: str = Field(default="medium", pattern="^(low|medium|high|urgent)$")
due_date: datetime | None = None
assigned_to: str | None = None
contact_id: str | None = None
class TaskUpdate(BaseModel):
"""Schema for updating a task."""
title: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
status: str | None = Field(default=None, pattern="^(open|in_progress|done)$")
priority: str | None = Field(default=None, pattern="^(low|medium|high|urgent)$")
due_date: datetime | None = None
assigned_to: str | None = None
contact_id: str | None = None
class TaskAssignRequest(BaseModel):
"""Schema for assigning a task."""
assigned_to: str = Field(..., description="User ID to assign the task to")
class TaskStatusRequest(BaseModel):
"""Schema for updating task status."""
status: str = Field(..., pattern="^(open|in_progress|done)$")
class TaskResponse(BaseModel):
"""Schema for task response."""
id: str
title: str
description: str | None = None
status: str
priority: str
due_date: datetime | None = None
assigned_to: str | None = None
contact_id: str | None = None
created_by: str | None = None
created_at: datetime
updated_at: datetime
class TaskListResponse(BaseModel):
"""Paginated task list response."""
items: list[TaskResponse]
total: int
page: int
page_size: int
+212
View File
@@ -0,0 +1,212 @@
"""Business logic for the Tasks plugin."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.tasks.models import Task
def _task_to_dict(task: Task) -> dict[str, Any]:
"""Serialize a Task model to a dict."""
return {
"id": str(task.id),
"title": task.title,
"description": task.description,
"status": task.status,
"priority": task.priority,
"due_date": task.due_date.isoformat() if task.due_date else None,
"assigned_to": str(task.assigned_to) if task.assigned_to else None,
"contact_id": str(task.contact_id) if task.contact_id else None,
"created_by": str(task.created_by) if task.created_by else None,
"created_at": task.created_at.isoformat() if task.created_at else None,
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
}
async def list_tasks(
db: AsyncSession,
tenant_id: uuid.UUID,
*,
page: int = 1,
page_size: int = 25,
status: str | None = None,
priority: str | None = None,
assigned_to: str | None = None,
contact_id: str | None = None,
search: str | None = None,
) -> dict[str, Any]:
"""List tasks with filtering and pagination."""
query = select(Task).where(Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
if status:
query = query.where(Task.status == status)
if priority:
query = query.where(Task.priority == priority)
if assigned_to:
query = query.where(Task.assigned_to == uuid.UUID(assigned_to))
if contact_id:
query = query.where(Task.contact_id == uuid.UUID(contact_id))
if search:
query = query.where(Task.title.ilike(f"%{search}%"))
# Count total
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate
query = query.order_by(Task.due_date.asc().nulls_last(), Task.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
tasks = result.scalars().all()
return {
"items": [_task_to_dict(t) for t in tasks],
"total": total,
"page": page,
"page_size": page_size,
}
async def get_task(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID) -> dict[str, Any] | None:
"""Get a single task by ID."""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
)
task = result.scalar_one_or_none()
if task is None:
return None
return _task_to_dict(task)
async def create_task(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new task."""
task = Task(
tenant_id=tenant_id,
title=data["title"],
description=data.get("description"),
status=data.get("status", "open"),
priority=data.get("priority", "medium"),
due_date=data.get("due_date"),
assigned_to=uuid.UUID(data["assigned_to"]) if data.get("assigned_to") else None,
contact_id=uuid.UUID(data["contact_id"]) if data.get("contact_id") else None,
created_by=user_id,
)
db.add(task)
await db.flush()
return _task_to_dict(task)
async def update_task(
db: AsyncSession,
tenant_id: uuid.UUID,
task_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any] | None:
"""Update a task."""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
)
task = result.scalar_one_or_none()
if task is None:
return None
if "title" in data and data["title"] is not None:
task.title = data["title"]
if "description" in data:
task.description = data["description"]
if "status" in data and data["status"] is not None:
task.status = data["status"]
if "priority" in data and data["priority"] is not None:
task.priority = data["priority"]
if "due_date" in data:
task.due_date = data["due_date"]
if "assigned_to" in data:
task.assigned_to = uuid.UUID(data["assigned_to"]) if data["assigned_to"] else None
if "contact_id" in data:
task.contact_id = uuid.UUID(data["contact_id"]) if data["contact_id"] else None
await db.flush()
return _task_to_dict(task)
async def delete_task(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID) -> bool:
"""Soft-delete a task."""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
)
task = result.scalar_one_or_none()
if task is None:
return False
task.deleted_at = datetime.now(timezone.utc)
await db.flush()
return True
async def assign_task(
db: AsyncSession,
tenant_id: uuid.UUID,
task_id: uuid.UUID,
assigned_to: uuid.UUID,
) -> dict[str, Any] | None:
"""Assign a task to a user."""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
)
task = result.scalar_one_or_none()
if task is None:
return None
task.assigned_to = assigned_to
await db.flush()
return _task_to_dict(task)
async def update_task_status(
db: AsyncSession,
tenant_id: uuid.UUID,
task_id: uuid.UUID,
new_status: str,
) -> dict[str, Any] | None:
"""Update task status."""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
)
task = result.scalar_one_or_none()
if task is None:
return None
task.status = new_status
await db.flush()
return _task_to_dict(task)
async def get_due_tasks(
db: AsyncSession,
tenant_id: uuid.UUID,
*,
before: datetime | None = None,
) -> list[dict[str, Any]]:
"""Get tasks that are due (for ARQ reminder job)."""
now = before or datetime.now(timezone.utc)
result = await db.execute(
select(Task).where(
Task.tenant_id == tenant_id,
Task.deleted_at.is_(None),
Task.status != "done",
Task.due_date.is_not(None),
Task.due_date <= now,
)
)
tasks = result.scalars().all()
return [_task_to_dict(t) for t in tasks]
+18
View File
@@ -138,6 +138,21 @@ class FrontendDashboardWidget(BaseModel):
permission: str = Field(default="", description="Optional permission required")
class CustomFieldDefinition(BaseModel):
"""A custom field definition contributed by a plugin manifest."""
name: str = Field(..., min_length=1, max_length=80, description="Unique field name (snake_case)")
label: str = Field(default="", description="Human-readable label (fallback if i18n key missing)")
label_key: str = Field(default="", description="i18n key for the field label")
field_type: str = Field(
..., pattern="^(text|number|date|select|multiselect|boolean)$", description="Field type"
)
options: list[str] = Field(default_factory=list, description="Options for select/multiselect types")
default_value: Any = Field(default=None, description="Default value for the field")
required: bool = Field(default=False, description="Whether the field is required")
entity: str = Field(default="contact", description="Entity type this field applies to (contact/file/etc)")
class MiniAppContribution(BaseModel):
"""A MiniApp contributed by a plugin manifest."""
@@ -214,6 +229,9 @@ class PluginManifest(BaseModel):
miniapps: list[MiniAppContribution] = Field(
default_factory=list, description="MiniApps contributed by this plugin"
)
custom_fields: list[CustomFieldDefinition] = Field(
default_factory=list, description="Custom field definitions contributed by this plugin"
)
@field_validator("name")
+1
View File
@@ -760,6 +760,7 @@ class PluginRegistry:
"detail_tabs": [tab.model_dump() for tab in m.detail_tabs],
"settings_pages": [page.model_dump() for page in m.settings_pages],
"dashboard_widgets": [widget.model_dump() for widget in m.dashboard_widgets],
"custom_fields": [cf.model_dump() for cf in m.custom_fields],
}
)
return manifests
+158
View File
@@ -0,0 +1,158 @@
"""Custom fields routes — merge plugin definitions with stored values."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Body
from pydantic import BaseModel
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.contact import Contact
from app.plugins.registry import get_registry
router = APIRouter(prefix="/api/v1/contacts", tags=["custom-fields"])
class CustomFieldUpdateRequest(BaseModel):
"""Request body for updating custom field values."""
values: dict[str, Any] = {}
def _collect_custom_field_definitions(entity: str = "contact") -> list[dict[str, Any]]:
"""Collect all custom field definitions from active plugin manifests."""
definitions: list[dict[str, Any]] = []
seen_names: set[str] = set()
registry = get_registry()
for plugin in registry._plugins.values():
manifest = plugin.manifest
for cf in manifest.custom_fields:
if cf.entity != entity:
continue
if cf.name in seen_names:
continue
seen_names.add(cf.name)
definitions.append(
{
"name": cf.name,
"label": cf.label,
"label_key": cf.label_key,
"field_type": cf.field_type,
"options": cf.options,
"default_value": cf.default_value,
"required": cf.required,
"entity": cf.entity,
"plugin": manifest.name,
}
)
return definitions
def _merge_definitions_with_values(
definitions: list[dict[str, Any]], stored: dict[str, Any] | None
) -> list[dict[str, Any]]:
"""Merge field definitions with stored values, applying defaults."""
stored = stored or {}
result: list[dict[str, Any]] = []
for d in definitions:
name = d["name"]
value = stored.get(name, d.get("default_value"))
entry = {**d, "value": value}
result.append(entry)
return result
@router.get("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:read"))])
async def get_custom_fields(
contact_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get all custom fields for a contact (merged definitions + stored values)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
cid = uuid.UUID(contact_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None
result = await db.execute(
select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id)
)
contact = result.scalar_one_or_none()
if contact is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
definitions = _collect_custom_field_definitions("contact")
merged = _merge_definitions_with_values(definitions, contact.custom)
return {"fields": merged}
@router.patch("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:write"))])
async def update_custom_fields(
contact_id: str,
body: CustomFieldUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update custom field values for a contact (stored in contacts.custom JSONB)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
cid = uuid.UUID(contact_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None
result = await db.execute(
select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id)
)
contact = result.scalar_one_or_none()
if contact is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
# Validate against definitions
definitions = _collect_custom_field_definitions("contact")
def_map = {d["name"]: d for d in definitions}
current_custom = dict(contact.custom or {})
for name, value in body.values.items():
if name not in def_map:
raise HTTPException(
400,
detail={"detail": f"Unknown custom field: {name}", "code": "unknown_field"},
)
field_def = def_map[name]
# Validate required
if field_def["required"] and (value is None or value == ""):
raise HTTPException(
400,
detail={"detail": f"Field '{name}' is required", "code": "required_field"},
)
# Validate select/multiselect options
if field_def["field_type"] == "select" and value is not None:
if value not in field_def["options"]:
raise HTTPException(
400,
detail={"detail": f"Invalid option for field '{name}'", "code": "invalid_option"},
)
if field_def["field_type"] == "multiselect" and value is not None:
if not isinstance(value, list):
raise HTTPException(
400,
detail={"detail": f"Field '{name}' must be a list", "code": "invalid_type"},
)
for v in value:
if v not in field_def["options"]:
raise HTTPException(
400,
detail={"detail": f"Invalid option '{v}' for field '{name}'", "code": "invalid_option"},
)
current_custom[name] = value
contact.custom = current_custom
await db.flush()
merged = _merge_definitions_with_values(definitions, contact.custom)
return {"fields": merged}
+136
View File
@@ -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)
BIN
View File
Binary file not shown.
@@ -0,0 +1,147 @@
/**
* CustomFieldRenderer tests — renders custom fields based on field_type.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
import type { CustomFieldDefinition } from '@/api/customFields';
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Mock UI components
vi.mock('@/components/ui/Input', () => ({
Input: ({ value, onChange, type, id, ...props }: any) => (
<input
data-testid={props['data-testid'] || `input-${id}`}
type={type || 'text'}
value={value || ''}
onChange={onChange}
/>
),
}));
vi.mock('@/components/ui/Select', () => ({
Select: ({ value, onChange, id, options, children, ...props }: any) => (
<select
data-testid={props['data-testid'] || `select-${id}`}
value={value || ''}
onChange={onChange}
>
{options ? options.map((o: any) => <option key={o.value} value={o.value}>{o.label}</option>) : children}
</select>
),
}));
const mockFields: CustomFieldDefinition[] = [
{
name: 'lead_source',
label: 'Lead Source',
label_key: 'custom.leadSource',
field_type: 'select',
options: ['website', 'referral', 'cold_call'],
default_value: null,
required: false,
entity: 'contact',
plugin: 'test_plugin',
value: 'website',
},
{
name: 'score',
label: 'Score',
label_key: 'custom.score',
field_type: 'number',
options: [],
default_value: 0,
required: false,
entity: 'contact',
plugin: 'test_plugin',
value: 42,
},
{
name: 'active',
label: 'Active',
label_key: 'custom.active',
field_type: 'boolean',
options: [],
default_value: false,
required: false,
entity: 'contact',
plugin: 'test_plugin',
value: true,
},
];
describe('CustomFieldRenderer', () => {
it('renders read mode with field values', () => {
render(<CustomFieldRenderer fields={mockFields} mode="read" />);
expect(screen.getByTestId('custom-fields-read')).toBeInTheDocument();
expect(screen.getByText('website')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
expect(screen.getByText('✓')).toBeInTheDocument();
});
it('renders edit mode with form controls', () => {
render(
<CustomFieldRenderer
fields={mockFields}
mode="edit"
values={{ lead_source: 'website', score: 42, active: true }}
onChange={vi.fn()}
/>
);
expect(screen.getByTestId('custom-fields-edit')).toBeInTheDocument();
expect(screen.getByTestId('select-cf-lead_source')).toBeInTheDocument();
expect(screen.getByTestId('input-cf-score')).toBeInTheDocument();
expect(document.getElementById('cf-active')).toBeInTheDocument();
});
it('calls onChange when select value changes', () => {
const onChange = vi.fn();
render(
<CustomFieldRenderer
fields={[mockFields[0]]}
mode="edit"
values={{ lead_source: 'website' }}
onChange={onChange}
/>
);
const select = screen.getByTestId('select-cf-lead_source');
fireEvent.change(select, { target: { value: 'referral' } });
expect(onChange).toHaveBeenCalledWith('lead_source', 'referral');
});
it('renders nothing when fields array is empty', () => {
const { container } = render(<CustomFieldRenderer fields={[]} mode="read" />);
expect(container.firstChild).toBeNull();
});
it('renders multiselect field with checkboxes', () => {
const multiField: CustomFieldDefinition = {
name: 'tags',
label: 'Tags',
label_key: 'custom.tags',
field_type: 'multiselect',
options: ['vip', 'customer', 'lead'],
default_value: [],
required: false,
entity: 'contact',
plugin: 'test_plugin',
value: ['vip'],
};
render(
<CustomFieldRenderer
fields={[multiField]}
mode="edit"
values={{ tags: ['vip'] }}
onChange={vi.fn()}
/>
);
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(3);
expect(checkboxes[0]).toBeChecked();
});
});
@@ -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) => (
<div>
{label && <label>{label}</label>}
<input
data-testid={props['data-testid'] || 'input'}
value={value || ''}
onChange={onChange}
/>
</div>
),
}));
vi.mock('@/components/ui/Modal', () => ({
Modal: ({ open, onClose, title, children }: any) =>
open ? (
<div data-testid="modal" role="dialog">
<h2>{title}</h2>
<button onClick={onClose}>Close</button>
{children}
</div>
) : null,
}));
vi.mock('@/components/ui/Button', () => ({
Button: ({ children, onClick, disabled, ...props }: any) => (
<button onClick={onClick} disabled={disabled} data-testid={props['data-testid'] || 'button'}>
{children}
</button>
),
}));
// Mock saved filters API
vi.mock('@/api/savedFilters', () => ({
useSavedFilters: vi.fn(() => ({
data: [
{
id: 'filter-1',
name: 'Important Clients',
entity_type: 'contacts',
filter_criteria: { type: 'company' },
user_id: 'user-1',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
],
})),
useCreateSavedFilter: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
useDeleteSavedFilter: vi.fn(() => ({ mutateAsync: vi.fn() })),
}));
describe('SavedFilters', () => {
it('renders saved filter buttons', () => {
render(
<SavedFilters
entityType="contacts"
currentCriteria={{}}
onLoadFilter={vi.fn()}
/>
);
expect(screen.getByTestId('saved-filters')).toBeInTheDocument();
expect(screen.getByText('Important Clients')).toBeInTheDocument();
});
it('opens save modal when save button is clicked', () => {
render(
<SavedFilters
entityType="contacts"
currentCriteria={{}}
onLoadFilter={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('save-filter-btn'));
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByTestId('filter-name-input')).toBeInTheDocument();
});
it('calls onLoadFilter when a saved filter is clicked', () => {
const onLoadFilter = vi.fn();
render(
<SavedFilters
entityType="contacts"
currentCriteria={{}}
onLoadFilter={onLoadFilter}
/>
);
fireEvent.click(screen.getByText('Important Clients'));
expect(onLoadFilter).toHaveBeenCalledWith({ type: 'company' });
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Tasks page tests — basic rendering and interaction.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { TasksPage } from '@/pages/Tasks';
import * as tasksApi from '@/api/tasks';
// 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, type, label, ...props }: any) => (
<div>
{label && <label>{label}</label>}
<input
data-testid={props['data-testid'] || 'input'}
type={type || 'text'}
value={value || ''}
onChange={onChange}
/>
</div>
),
}));
vi.mock('@/components/ui/Select', () => ({
Select: ({ value, onChange, options, label, ...props }: any) => (
<div>
{label && <label>{label}</label>}
<select data-testid={props['data-testid'] || 'select'} value={value || ''} onChange={onChange}>
{options?.map((o: any) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
),
}));
vi.mock('@/components/ui/Modal', () => ({
Modal: ({ open, onClose, title, children }: any) =>
open ? (
<div data-testid="modal" role="dialog">
<h2>{title}</h2>
<button onClick={onClose}>Close</button>
{children}
</div>
) : null,
}));
vi.mock('@/components/ui/Button', () => ({
Button: ({ children, onClick, disabled, ...props }: any) => (
<button onClick={onClick} disabled={disabled} data-testid={props['data-testid'] || 'button'}>
{children}
</button>
),
}));
vi.mock('@/components/ui/Badge', () => ({
Badge: ({ children, variant }: any) => <span data-testid="badge" data-variant={variant}>{children}</span>,
}));
vi.mock('@/components/ui/EmptyState', () => ({
EmptyState: ({ title, description }: any) => (
<div data-testid="empty-state">
<h3>{title}</h3>
<p>{description}</p>
</div>
),
}));
// Mock tasks API
vi.mock('@/api/tasks', () => ({
useTasks: vi.fn(() => ({
data: {
items: [
{
id: 'task-1',
title: 'Test Task',
description: 'Test description',
status: 'open',
priority: 'high',
due_date: '2025-12-31T10:00:00Z',
assigned_to: null,
contact_id: null,
created_by: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
],
total: 1,
page: 1,
page_size: 25,
},
isLoading: false,
})),
useCreateTask: vi.fn(() => ({ mutateAsync: vi.fn() })),
useUpdateTask: vi.fn(() => ({ mutateAsync: vi.fn() })),
useDeleteTask: vi.fn(() => ({ mutateAsync: vi.fn() })),
useUpdateTaskStatus: vi.fn(() => ({ mutateAsync: vi.fn() })),
}));
describe('TasksPage', () => {
it('renders the tasks page with header', () => {
render(<TasksPage />);
expect(screen.getByTestId('tasks-page')).toBeInTheDocument();
expect(screen.getByText('tasks.title')).toBeInTheDocument();
});
it('renders task items from API', () => {
render(<TasksPage />);
expect(screen.getByText('Test Task')).toBeInTheDocument();
expect(screen.getByText('Test description')).toBeInTheDocument();
});
it('opens create modal when create button is clicked', () => {
render(<TasksPage />);
const createBtn = screen.getByText('tasks.create');
fireEvent.click(createBtn);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByTestId('task-title-input')).toBeInTheDocument();
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* Custom fields API hooks — merge plugin definitions with stored values.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPatch } from './client';
export interface CustomFieldDefinition {
name: string;
label: string;
label_key: string;
field_type: 'text' | 'number' | 'date' | 'select' | 'multiselect' | 'boolean';
options: string[];
default_value: any;
required: boolean;
entity: string;
plugin: string;
value: any;
}
export interface CustomFieldsResponse {
fields: CustomFieldDefinition[];
}
export function useCustomFields(contactId?: string) {
return useQuery({
queryKey: ['custom-fields', contactId],
queryFn: () => apiGet<CustomFieldsResponse>(`/contacts/${contactId}/custom-fields`),
enabled: !!contactId,
});
}
export function useUpdateCustomFields() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contactId, values }: { contactId: string; values: Record<string, any> }) =>
apiPatch<CustomFieldsResponse>(`/contacts/${contactId}/custom-fields`, { values }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['custom-fields', variables.contactId] });
},
});
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Saved filters API hooks — CRUD for reusable filter criteria.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiDelete } from './client';
export interface SavedFilter {
id: string;
name: string;
entity_type: string;
filter_criteria: Record<string, any>;
user_id: string;
created_at: string;
updated_at: string;
}
export interface SavedFilterCreateInput {
name: string;
entity_type: string;
filter_criteria: Record<string, any>;
}
export function useSavedFilters(entityType?: string) {
const params = new URLSearchParams();
if (entityType) params.set('entity_type', entityType);
return useQuery({
queryKey: ['saved-filters', entityType],
queryFn: () => apiGet<SavedFilter[]>(`/saved-filters?${params.toString()}`),
});
}
export function useCreateSavedFilter() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: SavedFilterCreateInput) => apiPost<SavedFilter>('/saved-filters', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['saved-filters'] });
},
});
}
export function useDeleteSavedFilter() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/saved-filters/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['saved-filters'] });
},
});
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Tasks API hooks — CRUD, assign, status update.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
export interface Task {
id: string;
title: string;
description: string | null;
status: 'open' | 'in_progress' | 'done';
priority: 'low' | 'medium' | 'high' | 'urgent';
due_date: string | null;
assigned_to: string | null;
contact_id: string | null;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface TaskListResponse {
items: Task[];
total: number;
page: number;
page_size: number;
}
export interface TaskCreateInput {
title: string;
description?: string | null;
status?: string;
priority?: string;
due_date?: string | null;
assigned_to?: string | null;
contact_id?: string | null;
}
export interface TaskUpdateInput {
title?: string;
description?: string | null;
status?: string;
priority?: string;
due_date?: string | null;
assigned_to?: string | null;
contact_id?: string | null;
}
export interface TaskFilter {
status?: string;
priority?: string;
assigned_to?: string;
contact_id?: string;
search?: string;
}
export function useTasks(page = 1, pageSize = 25, filter?: TaskFilter) {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (filter?.status) params.set('status', filter.status);
if (filter?.priority) params.set('priority', filter.priority);
if (filter?.assigned_to) params.set('assigned_to', filter.assigned_to);
if (filter?.contact_id) params.set('contact_id', filter.contact_id);
if (filter?.search) params.set('search', filter.search);
return useQuery({
queryKey: ['tasks', page, pageSize, filter],
queryFn: () => apiGet<TaskListResponse>(`/tasks?${params.toString()}`),
});
}
export function useTask(id?: string) {
return useQuery({
queryKey: ['tasks', id],
queryFn: () => apiGet<Task>(`/tasks/${id}`),
enabled: !!id,
});
}
export function useCreateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: TaskCreateInput) => apiPost<Task>('/tasks', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useUpdateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: TaskUpdateInput }) =>
apiPatch<Task>(`/tasks/${id}`, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
},
});
}
export function useDeleteTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/tasks/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useAssignTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, assignedTo }: { id: string; assignedTo: string }) =>
apiPost<Task>(`/tasks/${id}/assign`, { assigned_to: assignedTo }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
},
});
}
export function useUpdateTaskStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
apiPost<Task>(`/tasks/${id}/status`, { status }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
},
});
}
+119
View File
@@ -0,0 +1,119 @@
/**
* SavedFilters — filter-builder UI, save button, load saved filters.
* Integrates into list views (Contacts, Mail, Calendar, DMS).
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { useToast } from '@/components/ui/Toast';
import { Bookmark, Trash2, Save } from 'lucide-react';
import {
useSavedFilters,
useCreateSavedFilter,
useDeleteSavedFilter,
type SavedFilter,
} from '@/api/savedFilters';
export interface SavedFiltersProps {
entityType: string;
currentCriteria: Record<string, any>;
onLoadFilter: (criteria: Record<string, any>) => void;
}
export function SavedFilters({ entityType, currentCriteria, onLoadFilter }: SavedFiltersProps) {
const { t } = useTranslation();
const toast = useToast();
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [filterName, setFilterName] = useState('');
const { data: savedFilters = [] } = useSavedFilters(entityType);
const createMutation = useCreateSavedFilter();
const deleteMutation = useDeleteSavedFilter();
const handleSave = async () => {
if (!filterName.trim()) return;
try {
await createMutation.mutateAsync({
name: filterName.trim(),
entity_type: entityType,
filter_criteria: currentCriteria,
});
toast.success(t('savedFilters.saved'));
setFilterName('');
setSaveModalOpen(false);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDelete = async (id: string) => {
try {
await deleteMutation.mutateAsync(id);
toast.success(t('savedFilters.deleted'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleLoad = (filter: SavedFilter) => {
onLoadFilter(filter.filter_criteria);
};
return (
<div className="flex items-center gap-2 flex-wrap" data-testid="saved-filters">
{/* Saved filter tabs/buttons */}
{savedFilters.map((filter) => (
<div
key={filter.id}
className="flex items-center gap-1 px-2 py-1 rounded-md bg-secondary-100 hover:bg-secondary-200 cursor-pointer group"
onClick={() => handleLoad(filter)}
data-testid={`saved-filter-${filter.id}`}
>
<Bookmark className="w-3 h-3 text-secondary-500" />
<span className="text-xs text-secondary-700">{filter.name}</span>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(filter.id); }}
className="opacity-0 group-hover:opacity-100 text-danger-500 hover:text-danger-700"
aria-label={t('common.delete')}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
{/* Save current filter button */}
<Button
size="sm"
variant="ghost"
onClick={() => setSaveModalOpen(true)}
data-testid="save-filter-btn"
>
<Save className="w-3 h-3 mr-1" />
{t('savedFilters.save')}
</Button>
{/* Save modal */}
<Modal open={saveModalOpen} onClose={() => setSaveModalOpen(false)} title={t('savedFilters.saveTitle')} size="sm">
<div className="space-y-4">
<Input
label={t('savedFilters.name')}
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
placeholder={t('savedFilters.namePlaceholder')}
data-testid="filter-name-input"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setSaveModalOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSave} disabled={!filterName.trim() || createMutation.isPending} data-testid="save-filter-confirm">
{t('common.save')}
</Button>
</div>
</div>
</Modal>
</div>
);
}
@@ -14,6 +14,8 @@ import { usePluginStore } from '@/store/pluginStore';
import { useAIUIControlStore } from '@/store/aiUIControlStore';
import { PluginPage } from '@/components/plugins/PluginLoader';
import { useAuthStore } from '@/store/authStore';
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
import { useCustomFields } from '@/api/customFields';
import {
type UnifiedContact,
type ContactPerson,
@@ -177,6 +179,10 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
}, [aiActiveModal]);
const user = useAuthStore(s => s.user);
// Custom fields from plugin definitions
const customFieldDefs = usePluginStore(s => s.getCustomFieldsForEntity('contact'));
const { data: customFieldsData } = useCustomFields(contact?.id);
if (loading) {
return (
<div className="flex items-center justify-center py-12" data-testid="contact-detail-loading">
@@ -420,11 +426,12 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
</Section>
{/* Custom Fields */}
{contact.custom && Object.keys(contact.custom).length > 0 && (
{contact.id && customFieldDefs.length > 0 && (
<Section title={t('contacts.customFields')}>
<pre className="text-xs text-secondary-700 bg-secondary-50 rounded p-2 overflow-x-auto">
{JSON.stringify(contact.custom, null, 2)}
</pre>
<CustomFieldRenderer
fields={customFieldsData?.fields || []}
mode="read"
/>
</Section>
)}
@@ -13,6 +13,9 @@ import {
useCreateUnifiedContact,
useUpdateUnifiedContact,
} from '@/api/hooks';
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
import { useCustomFields, useUpdateCustomFields } from '@/api/customFields';
import { usePluginStore } from '@/store/pluginStore';
export interface ContactEditModalProps {
open: boolean;
@@ -105,6 +108,26 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
const currentType = watch('type');
// Custom fields
const customFieldDefs = usePluginStore(s => s.getCustomFieldsForEntity('contact'));
const { data: customFieldsData } = useCustomFields(contact?.id);
const updateCustomFields = useUpdateCustomFields();
const [customValues, setCustomValues] = React.useState<Record<string, any>>({});
React.useEffect(() => {
if (open && customFieldsData?.fields) {
const vals: Record<string, any> = {};
for (const f of customFieldsData.fields) {
vals[f.name] = f.value ?? f.default_value ?? null;
}
setCustomValues(vals);
}
}, [open, customFieldsData]);
const handleCustomFieldChange = (name: string, value: any) => {
setCustomValues(prev => ({ ...prev, [name]: value }));
};
// Reset form when modal opens
useEffect(() => {
if (open) {
@@ -164,15 +187,27 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
};
try {
let savedId: string | undefined;
if (isEdit && contact) {
await updateMutation.mutateAsync({ id: contact.id, data });
savedId = contact.id;
toast.success(t('contacts.updated'));
onSaved?.(contact.id);
} else {
const result = await createMutation.mutateAsync(data) as { id: string };
savedId = result.id;
toast.success(t('contacts.created'));
onSaved?.(result.id);
}
// Save custom fields if any definitions exist and we have a contact ID
if (savedId && customFieldDefs.length > 0 && Object.keys(customValues).length > 0) {
try {
await updateCustomFields.mutateAsync({ contactId: savedId, values: customValues });
} catch (cfErr: any) {
// Don't fail the whole save if custom fields fail
console.error('Custom fields save failed:', cfErr);
}
}
onClose();
} catch (err: any) {
toast.error(err.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed')));
@@ -281,6 +316,19 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
</div>
</div>
{/* Custom Fields */}
{customFieldDefs.length > 0 && (
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.customFields')}</h3>
<CustomFieldRenderer
fields={customFieldsData?.fields || customFieldDefs.map(d => ({ ...d, value: d.default_value, plugin: '' }))}
mode="edit"
values={customValues}
onChange={handleCustomFieldChange}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
@@ -0,0 +1,146 @@
/**
* CustomFieldRenderer — renders custom fields based on field_type.
* Supports read-only and editable modes.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import type { CustomFieldDefinition } from '@/api/customFields';
export interface CustomFieldRendererProps {
fields: CustomFieldDefinition[];
mode: 'read' | 'edit';
values?: Record<string, any>;
onChange?: (name: string, value: any) => void;
}
function formatValue(value: any, fieldType: string): string {
if (value === null || value === undefined || value === '') return '—';
if (fieldType === 'boolean') return value ? '✓' : '✗';
if (fieldType === 'multiselect' && Array.isArray(value)) return value.join(', ');
if (fieldType === 'date' && value) {
try {
return new Date(value).toLocaleDateString();
} catch {
return String(value);
}
}
return String(value);
}
export function CustomFieldRenderer({ fields, mode, values = {}, onChange }: CustomFieldRendererProps) {
const { t } = useTranslation();
if (!fields || fields.length === 0) return null;
if (mode === 'read') {
return (
<dl className="grid grid-cols-2 gap-3" data-testid="custom-fields-read">
{fields.map((field) => {
const label = field.label_key ? t(field.label_key) : field.label || field.name;
const value = values[field.name] ?? field.value;
return (
<div key={field.name}>
<dt className="text-xs font-medium text-secondary-500">{label}</dt>
<dd className="text-sm text-secondary-900">{formatValue(value, field.field_type)}</dd>
</div>
);
})}
</dl>
);
}
return (
<div className="grid grid-cols-2 gap-3" data-testid="custom-fields-edit">
{fields.map((field) => {
const label = field.label_key ? t(field.label_key) : field.label || field.name;
const value = values[field.name] ?? field.value ?? field.default_value ?? '';
if (field.field_type === 'boolean') {
return (
<div key={field.name} className="flex items-center gap-2">
<input
type="checkbox"
id={`cf-${field.name}`}
checked={!!value}
onChange={(e) => onChange?.(field.name, e.target.checked)}
className="h-4 w-4 rounded border-secondary-300"
/>
<label htmlFor={`cf-${field.name}`} className="text-sm text-secondary-700">
{label}{field.required && ' *'}
</label>
</div>
);
}
if (field.field_type === 'select') {
return (
<div key={field.name}>
<label htmlFor={`cf-${field.name}`} className="text-xs font-medium text-secondary-500">
{label}{field.required && ' *'}
</label>
<Select
id={`cf-${field.name}`}
value={value || ''}
options={field.options.map((opt) => ({ value: opt, label: opt }))}
onChange={(e) => onChange?.(field.name, e.target.value || null)}
/>
</div>
);
}
if (field.field_type === 'multiselect') {
const selected: string[] = Array.isArray(value) ? value : [];
return (
<div key={field.name}>
<label htmlFor={`cf-${field.name}`} className="text-xs font-medium text-secondary-500">
{label}{field.required && ' *'}
</label>
<div className="flex flex-wrap gap-2 mt-1">
{field.options.map((opt) => (
<label key={opt} className="flex items-center gap-1 text-sm">
<input
type="checkbox"
checked={selected.includes(opt)}
onChange={(e) => {
if (e.target.checked) {
onChange?.(field.name, [...selected, opt]);
} else {
onChange?.(field.name, selected.filter((v) => v !== opt));
}
}}
className="h-4 w-4 rounded border-secondary-300"
/>
{opt}
</label>
))}
</div>
</div>
);
}
// text, number, date
return (
<div key={field.name}>
<label htmlFor={`cf-${field.name}`} className="text-xs font-medium text-secondary-500">
{label}{field.required && ' *'}
</label>
<Input
id={`cf-${field.name}`}
type={field.field_type === 'number' ? 'number' : field.field_type === 'date' ? 'date' : 'text'}
value={value ?? ''}
onChange={(e) => {
let val: any = e.target.value;
if (field.field_type === 'number' && val !== '') val = Number(val);
if (val === '') val = null;
onChange?.(field.name, val);
}}
/>
</div>
);
})}
</div>
);
}
@@ -21,6 +21,7 @@ const mockManifests: PluginUiManifest[] = [
detail_tabs: [],
settings_pages: [],
dashboard_widgets: [],
custom_fields: [],
},
];
@@ -29,6 +29,7 @@ const mockManifests: PluginUiManifest[] = [
detail_tabs: [],
settings_pages: [],
dashboard_widgets: [],
custom_fields: [],
},
{
name: 'calendar_plugin',
@@ -42,6 +43,7 @@ const mockManifests: PluginUiManifest[] = [
detail_tabs: [],
settings_pages: [],
dashboard_widgets: [],
custom_fields: [],
},
];
+37 -1
View File
@@ -18,7 +18,8 @@
"settings": "Einstellungen",
"aiAssistant": "KI Assistent",
"mcpSettings": "MCP Einstellungen",
"reports": "Reports"
"reports": "Reports",
"tasks": "Aufgaben"
},
"auth": {
"login": "Anmelden",
@@ -996,5 +997,40 @@
"selectTemplateHint": "Wählen Sie eine Vorlage aus der Liste",
"downloadHistory": "Download-Verlauf",
"noDownloads": "Noch keine Downloads"
},
"tasks": {
"title": "Aufgaben",
"create": "Neue Aufgabe",
"edit": "Aufgabe bearbeiten",
"deleteConfirm": "Diese Aufgabe löschen?",
"created": "Aufgabe erstellt",
"updated": "Aufgabe aktualisiert",
"deleted": "Aufgabe gelöscht",
"statusUpdated": "Status aktualisiert",
"noTasks": "Keine Aufgaben",
"noTasksDesc": "Es wurden noch keine Aufgaben erstellt.",
"markDone": "Erledigt",
"dueDate": "Fälligkeitsdatum",
"title_field": "Titel",
"description": "Beschreibung",
"status_field": "Status",
"priority_field": "Priorität",
"statusOpen": "Offen",
"statusInProgress": "In Bearbeitung",
"statusDone": "Erledigt",
"priorityLow": "Niedrig",
"priorityMedium": "Mittel",
"priorityHigh": "Hoch",
"priorityUrgent": "Dringend"
},
"savedFilters": {
"save": "Filter speichern",
"saveTitle": "Filter speichern",
"name": "Filtername",
"namePlaceholder": "z.B. Wichtige Kunden",
"saved": "Filter gespeichert",
"deleted": "Filter gelöscht",
"load": "Filter laden",
"noFilters": "Keine gespeicherten Filter"
}
}
+37 -1
View File
@@ -18,7 +18,8 @@
"settings": "Settings",
"aiAssistant": "AI Assistant",
"mcpSettings": "MCP Settings",
"reports": "Reports"
"reports": "Reports",
"tasks": "Tasks"
},
"auth": {
"login": "Sign In",
@@ -996,5 +997,40 @@
"selectTemplateHint": "Select a template from the list",
"downloadHistory": "Download History",
"noDownloads": "No downloads yet"
},
"tasks": {
"title": "Tasks",
"create": "New Task",
"edit": "Edit Task",
"deleteConfirm": "Delete this task?",
"created": "Task created",
"updated": "Task updated",
"deleted": "Task deleted",
"statusUpdated": "Status updated",
"noTasks": "No tasks",
"noTasksDesc": "No tasks have been created yet.",
"markDone": "Mark Done",
"dueDate": "Due Date",
"title_field": "Title",
"description": "Description",
"status_field": "Status",
"priority_field": "Priority",
"statusOpen": "Open",
"statusInProgress": "In Progress",
"statusDone": "Done",
"priorityLow": "Low",
"priorityMedium": "Medium",
"priorityHigh": "High",
"priorityUrgent": "Urgent"
},
"savedFilters": {
"save": "Save Filter",
"saveTitle": "Save Filter",
"name": "Filter Name",
"namePlaceholder": "e.g. Important Clients",
"saved": "Filter saved",
"deleted": "Filter deleted",
"load": "Load Filter",
"noFilters": "No saved filters"
}
}
+16
View File
@@ -16,6 +16,7 @@ import { ContactFolderTree, type ContactFilter } from '@/components/contacts/Con
import { ContactList, type ContactViewMode } from '@/components/contacts/ContactList';
import { ContactDetail } from '@/components/contacts/ContactDetail';
import { ContactEditModal } from '@/components/contacts/ContactEditModal';
import { SavedFilters } from '@/components/SavedFilters';
import { ArrowDownAZ, ArrowUpZA, ChevronLeft, LayoutGrid, List, Plus } from 'lucide-react';
import {
useUnifiedContacts,
@@ -328,6 +329,21 @@ export function ContactsListPage() {
</button>
</div>
{/* Saved Filters */}
<div className="px-3 py-1.5 border-b border-secondary-200 bg-secondary-50 flex-shrink-0">
<SavedFilters
entityType="contacts"
currentCriteria={{ search: debouncedSearch, type: contactType, sortBy, sortOrder, folderId }}
onLoadFilter={(criteria) => {
if (criteria.search) setSearch(criteria.search); else setSearch('');
if (criteria.sortBy) setSortBy(criteria.sortBy);
if (criteria.sortOrder) setSortOrder(criteria.sortOrder);
if (criteria.type) setSelectedFilter(criteria.type as ContactFilter);
setPage(1);
}}
/>
</div>
{/* List */}
<div className="flex-1 min-h-0">
<ContactList
+417
View File
@@ -0,0 +1,417 @@
/**
* Tasks page — list with filter, create modal, detail.
*/
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Modal } from '@/components/ui/Modal';
import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState';
import { useToast } from '@/components/ui/Toast';
import { Loader2, Plus, CheckSquare, Clock, AlertCircle } from 'lucide-react';
import {
useTasks,
useCreateTask,
useUpdateTask,
useDeleteTask,
useUpdateTaskStatus,
type Task,
type TaskFilter,
} from '@/api/tasks';
const STATUS_COLORS: Record<string, 'secondary' | 'info' | 'success'> = {
open: 'secondary',
in_progress: 'info',
done: 'success',
};
const PRIORITY_COLORS: Record<string, 'secondary' | 'info' | 'warning' | 'danger'> = {
low: 'secondary',
medium: 'info',
high: 'warning',
urgent: 'danger',
};
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
try {
return new Date(dateStr).toLocaleDateString();
} catch {
return dateStr;
}
}
function isOverdue(dateStr: string | null, status: string): boolean {
if (!dateStr || status === 'done') return false;
try {
return new Date(dateStr) < new Date();
} catch {
return false;
}
}
export function TasksPage() {
const { t } = useTranslation();
const toast = useToast();
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<TaskFilter>({});
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
// Debounce search
React.useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);
const effectiveFilter = useMemo(
() => ({ ...filter, search: debouncedSearch || undefined }),
[filter, debouncedSearch],
);
const { data, isLoading } = useTasks(page, 25, effectiveFilter);
const createMutation = useCreateTask();
const updateMutation = useUpdateTask();
const deleteMutation = useDeleteTask();
const statusMutation = useUpdateTaskStatus();
const tasks = data?.items || [];
const handleCreate = async (formData: Partial<Task>) => {
try {
await createMutation.mutateAsync({
title: formData.title || '',
description: formData.description || null,
priority: formData.priority || 'medium',
status: formData.status || 'open',
due_date: formData.due_date || null,
contact_id: formData.contact_id || null,
});
toast.success(t('tasks.created'));
setCreateModalOpen(false);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleUpdate = async (formData: Partial<Task>) => {
if (!editingTask) return;
try {
await updateMutation.mutateAsync({ id: editingTask.id, data: formData });
toast.success(t('tasks.updated'));
setEditingTask(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDelete = async (task: Task) => {
if (!window.confirm(t('tasks.deleteConfirm'))) return;
try {
await deleteMutation.mutateAsync(task.id);
toast.success(t('tasks.deleted'));
setSelectedTask(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleStatusChange = async (task: Task, newStatus: string) => {
try {
await statusMutation.mutateAsync({ id: task.id, status: newStatus });
toast.success(t('tasks.statusUpdated'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
return (
<div className="flex flex-col h-full" data-testid="tasks-page">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200 bg-white">
<h1 className="text-lg font-semibold text-secondary-900 flex items-center gap-2">
<CheckSquare className="w-5 h-5" />
{t('tasks.title')}
</h1>
<Button size="sm" onClick={() => { setEditingTask(null); setCreateModalOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />
{t('tasks.create')}
</Button>
</div>
{/* Filters */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-200 bg-white flex-wrap">
<Input
type="text"
placeholder={t('common.search')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-48"
/>
<Select
options={[
{ value: '', label: t('common.all') },
{ value: 'open', label: t('tasks.statusOpen') },
{ value: 'in_progress', label: t('tasks.statusInProgress') },
{ value: 'done', label: t('tasks.statusDone') },
]}
value={filter.status || ''}
onChange={(e) => { setFilter(f => ({ ...f, status: e.target.value || undefined })); setPage(1); }}
className="w-40"
/>
<Select
options={[
{ value: '', label: t('common.all') },
{ value: 'low', label: t('tasks.priorityLow') },
{ value: 'medium', label: t('tasks.priorityMedium') },
{ value: 'high', label: t('tasks.priorityHigh') },
{ value: 'urgent', label: t('tasks.priorityUrgent') },
]}
value={filter.priority || ''}
onChange={(e) => { setFilter(f => ({ ...f, priority: e.target.value || undefined })); setPage(1); }}
className="w-40"
/>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" />
</div>
) : tasks.length === 0 ? (
<EmptyState title={t('tasks.noTasks')} description={t('tasks.noTasksDesc')} />
) : (
<ul className="divide-y divide-secondary-100" role="list">
{tasks.map((task) => {
const overdue = isOverdue(task.due_date, task.status);
return (
<li
key={task.id}
className="px-4 py-3 hover:bg-secondary-50 cursor-pointer"
onClick={() => setSelectedTask(task)}
data-testid={`task-item-${task.id}`}
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-secondary-900">{task.title}</span>
{overdue && (
<AlertCircle className="w-4 h-4 text-danger-500" />
)}
</div>
{task.description && (
<p className="text-xs text-secondary-500 truncate mt-0.5">{task.description}</p>
)}
<div className="flex items-center gap-2 mt-1">
<Badge variant={STATUS_COLORS[task.status] || 'secondary'}>
{t(`tasks.status${task.status.charAt(0).toUpperCase() + task.status.slice(1).replace('_', '')}`)}
</Badge>
<Badge variant={PRIORITY_COLORS[task.priority] || 'secondary'}>
{t(`tasks.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`)}
</Badge>
{task.due_date && (
<span className={`text-xs flex items-center gap-1 ${overdue ? 'text-danger-600' : 'text-secondary-500'}`}>
<Clock className="w-3 h-3" />
{formatDate(task.due_date)}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{task.status !== 'done' && (
<button
onClick={(e) => { e.stopPropagation(); handleStatusChange(task, 'done'); }}
className="text-xs text-success-600 hover:text-success-700 px-2 py-1 min-h-touch"
>
{t('tasks.markDone')}
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); setEditingTask(task); }}
className="text-xs text-primary-600 hover:text-primary-700 px-2 py-1 min-h-touch"
>
{t('common.edit')}
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(task); }}
className="text-xs text-danger-600 hover:text-danger-700 px-2 py-1 min-h-touch"
>
{t('common.delete')}
</button>
</div>
</div>
</li>
);
})}
</ul>
)}
{/* Pagination */}
{data && data.total > 25 && (
<div className="flex items-center justify-between px-4 py-2 border-t border-secondary-200">
<span className="text-xs text-secondary-500">
{((page - 1) * 25) + 1}–{Math.min(page * 25, data.total)} / {data.total}
</span>
<div className="flex gap-2">
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
{t('common.back')}
</Button>
<Button size="sm" variant="secondary" disabled={page * 25 >= data.total} onClick={() => setPage(p => p + 1)}>
{t('common.next')}
</Button>
</div>
</div>
)}
</div>
{/* Create/Edit Modal */}
<TaskModal
open={createModalOpen || !!editingTask}
onClose={() => { setCreateModalOpen(false); setEditingTask(null); }}
task={editingTask}
onSubmit={editingTask ? handleUpdate : handleCreate}
/>
{/* Detail Modal */}
{selectedTask && (
<Modal open={!!selectedTask} onClose={() => setSelectedTask(null)} title={selectedTask.title} size="lg">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Badge variant={STATUS_COLORS[selectedTask.status] || 'secondary'}>
{t(`tasks.status${selectedTask.status.charAt(0).toUpperCase() + selectedTask.status.slice(1).replace('_', '')}`)}
</Badge>
<Badge variant={PRIORITY_COLORS[selectedTask.priority] || 'secondary'}>
{t(`tasks.priority${selectedTask.priority.charAt(0).toUpperCase() + selectedTask.priority.slice(1)}`)}
</Badge>
</div>
{selectedTask.description && (
<p className="text-sm text-secondary-700">{selectedTask.description}</p>
)}
<div className="text-xs text-secondary-500">
<span className="font-medium">{t('tasks.dueDate')}:</span> {formatDate(selectedTask.due_date)}
</div>
<div className="flex gap-2 pt-2">
{selectedTask.status !== 'done' && (
<Button size="sm" onClick={() => { handleStatusChange(selectedTask, 'done'); setSelectedTask(null); }}>
{t('tasks.markDone')}
</Button>
)}
<Button size="sm" variant="secondary" onClick={() => { setEditingTask(selectedTask); setSelectedTask(null); }}>
{t('common.edit')}
</Button>
<Button size="sm" variant="danger" onClick={() => handleDelete(selectedTask)}>
{t('common.delete')}
</Button>
</div>
</div>
</Modal>
)}
</div>
);
}
// ── Task Create/Edit Modal ──
function TaskModal({
open,
onClose,
task,
onSubmit,
}: {
open: boolean;
onClose: () => void;
task?: Task | null;
onSubmit: (data: Partial<Task>) => void;
}) {
const { t } = useTranslation();
const [title, setTitle] = useState(task?.title || '');
const [description, setDescription] = useState(task?.description || '');
const [priority, setPriority] = useState<string>(task?.priority || 'medium');
const [status, setStatus] = useState<string>(task?.status || 'open');
const [dueDate, setDueDate] = useState(task?.due_date ? task.due_date.slice(0, 10) : '');
React.useEffect(() => {
if (open) {
setTitle(task?.title || '');
setDescription(task?.description || '');
setPriority(task?.priority || 'medium');
setStatus(task?.status || 'open');
setDueDate(task?.due_date ? task.due_date.slice(0, 10) : '');
}
}, [open, task]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
onSubmit({
title: title.trim(),
description: description || null,
priority: priority as any,
status: status as any,
due_date: dueDate ? new Date(dueDate).toISOString() : null,
});
};
return (
<Modal open={open} onClose={onClose} title={task ? t('tasks.edit') : t('tasks.create')} size="md">
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label={t('tasks.title_field')}
value={title}
onChange={(e) => setTitle(e.target.value)}
required
data-testid="task-title-input"
/>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('tasks.description')}</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Select
label={t('tasks.status_field')}
options={[
{ value: 'open', label: t('tasks.statusOpen') },
{ value: 'in_progress', label: t('tasks.statusInProgress') },
{ value: 'done', label: t('tasks.statusDone') },
]}
value={status}
onChange={(e) => setStatus(e.target.value)}
/>
<Select
label={t('tasks.priority_field')}
options={[
{ value: 'low', label: t('tasks.priorityLow') },
{ value: 'medium', label: t('tasks.priorityMedium') },
{ value: 'high', label: t('tasks.priorityHigh') },
{ value: 'urgent', label: t('tasks.priorityUrgent') },
]}
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<Input
label={t('tasks.dueDate')}
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button type="submit" data-testid="task-submit-btn">{t('common.save')}</Button>
</div>
</form>
</Modal>
);
}
+2
View File
@@ -40,6 +40,7 @@ const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashb
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage })));
const ReportsPage = React.lazy(() => import('@/pages/Reports').then(m => ({ default: m.ReportsPage })));
const TasksPage = React.lazy(() => import('@/pages/Tasks').then(m => ({ default: m.TasksPage })));
/** Centered spinner fallback for lazy-loaded routes */
function PageLoader() {
@@ -91,6 +92,7 @@ const router = createBrowserRouter([
{ path: '/automation', element: withSuspense(<AutomationDashboardPage />) },
{ path: '/agents', element: withSuspense(<AgentDashboardPage />) },
{ path: '/reports', element: withSuspense(<ReportsPage />) },
{ path: '/tasks', element: withSuspense(<TasksPage />) },
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
{
path: '/settings',
@@ -25,6 +25,7 @@ const mockManifests: PluginUiManifest[] = [
dashboard_widgets: [
{ id: 'widget_a', label_key: 'widgets.a', label: 'Widget A', component: '@/components/WidgetA', icon: 'A', order: 200, col_span: 2, row_span: 1, permission: '' },
],
custom_fields: [],
},
{
name: 'plugin_b',
@@ -46,6 +47,7 @@ const mockManifests: PluginUiManifest[] = [
dashboard_widgets: [
{ id: 'widget_b', label_key: 'widgets.b', label: 'Widget B', component: '@/components/WidgetB', icon: 'B', order: 100, col_span: 1, row_span: 1, permission: '' },
],
custom_fields: [],
},
];
+20
View File
@@ -50,6 +50,17 @@ export interface PluginDashboardWidget {
permission: string;
}
export interface PluginCustomFieldDefinition {
name: string;
label: string;
label_key: string;
field_type: 'text' | 'number' | 'date' | 'select' | 'multiselect' | 'boolean';
options: string[];
default_value: any;
required: boolean;
entity: string;
}
export interface PluginUiManifest {
name: string;
display_name: string;
@@ -60,6 +71,7 @@ export interface PluginUiManifest {
detail_tabs: PluginDetailTab[];
settings_pages: PluginSettingsPage[];
dashboard_widgets: PluginDashboardWidget[];
custom_fields: PluginCustomFieldDefinition[];
}
interface PluginState {
@@ -77,6 +89,7 @@ interface PluginState {
getDetailTabsForEntity: (entityType: string) => PluginDetailTab[];
getAllSettingsPages: () => PluginSettingsPage[];
getAllDashboardWidgets: () => PluginDashboardWidget[];
getCustomFieldsForEntity: (entityType: string) => PluginCustomFieldDefinition[];
}
export const usePluginStore = create<PluginState>((set, get) => ({
@@ -125,4 +138,11 @@ export const usePluginStore = create<PluginState>((set, get) => ({
.flatMap((m) => m.dashboard_widgets)
.sort((a, b) => a.order - b.order);
},
getCustomFieldsForEntity: (entityType: string) => {
const { manifests } = get();
return manifests
.flatMap((m) => m.custom_fields || [])
.filter((cf) => cf.entity === entityType);
},
}));
+4
View File
@@ -80,6 +80,9 @@ from app.plugins.builtins.report_generator.models import ( # noqa: F401
ReportTemplate,
)
from app.plugins.builtins.tags.models import Tag, TagAssignment # noqa: F401
from app.plugins.builtins.tasks import TasksPlugin # noqa: F401
from app.plugins.builtins.tasks.models import Task # noqa: F401
from app.models.saved_filter import SavedFilter # noqa: F401
from app.plugins.registry import reset_registry_for_testing # noqa: F401
from app.services.plugin_service import reset_plugin_service_for_testing # noqa: F401
@@ -351,6 +354,7 @@ async def dms_app(engine: AsyncEngine, redis_client):
registry.register_plugin(PermissionsPlugin())
registry.register_plugin(DmsPlugin())
registry.register_plugin(TasksPlugin())
reset_plugin_service_for_testing(registry)
yield app
+164
View File
@@ -0,0 +1,164 @@
"""Custom fields tests — plugin-defined custom fields on contacts."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
class TestCustomFieldsGet:
"""GET /api/v1/contacts/{id}/custom-fields"""
async def test_get_custom_fields_returns_200(self, client: AsyncClient, db_session):
"""GET custom fields for a contact returns 200 with merged definitions."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
# Create a contact first
resp = await client.post(
"/api/v1/contacts",
json={"type": "company", "name": "Test Corp", "displayname": "Test Corp"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
contact_id = resp.json()["id"]
resp = await client.get(
f"/api/v1/contacts/{contact_id}/custom-fields",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert "fields" in data
assert isinstance(data["fields"], list)
async def test_get_custom_fields_invalid_uuid_returns_400(self, client: AsyncClient, db_session):
"""GET custom fields with invalid UUID returns 400."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get(
"/api/v1/contacts/not-a-uuid/custom-fields",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
async def test_get_custom_fields_not_found_returns_404(self, client: AsyncClient, db_session):
"""GET custom fields for non-existent contact returns 404."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get(
"/api/v1/contacts/00000000-0000-0000-0000-000000000000/custom-fields",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
class TestCustomFieldsUpdate:
"""PATCH /api/v1/contacts/{id}/custom-fields"""
async def test_update_custom_fields_returns_200(self, client: AsyncClient, db_session):
"""PATCH custom fields stores values in contacts.custom JSONB."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
# Create a contact
resp = await client.post(
"/api/v1/contacts",
json={"type": "company", "name": "Custom Corp", "displayname": "Custom Corp"},
headers=ORIGIN_HEADER,
)
contact_id = resp.json()["id"]
# Update custom fields (empty values is valid since no plugin defines fields)
resp = await client.patch(
f"/api/v1/contacts/{contact_id}/custom-fields",
json={"values": {"test_field": "test_value"}},
headers=ORIGIN_HEADER,
)
# Without plugin definitions, unknown fields should be rejected
assert resp.status_code == 400
async def test_update_custom_fields_empty_values_returns_200(self, client: AsyncClient, db_session):
"""PATCH custom fields with empty values dict returns 200."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/contacts",
json={"type": "company", "name": "Empty Corp", "displayname": "Empty Corp"},
headers=ORIGIN_HEADER,
)
contact_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/contacts/{contact_id}/custom-fields",
json={"values": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert "fields" in resp.json()
async def test_update_custom_fields_not_found_returns_404(self, client: AsyncClient, db_session):
"""PATCH custom fields for non-existent contact returns 404."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.patch(
"/api/v1/contacts/00000000-0000-0000-0000-000000000000/custom-fields",
json={"values": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
class TestCustomFieldDefinition:
"""Test CustomFieldDefinition model in manifest."""
async def test_custom_field_definition_creation(self):
"""CustomFieldDefinition can be created with valid field types."""
from app.plugins.manifest import CustomFieldDefinition
cf = CustomFieldDefinition(
name="lead_source",
label="Lead Source",
label_key="custom.leadSource",
field_type="select",
options=["website", "referral", "cold_call"],
required=False,
entity="contact",
)
assert cf.name == "lead_source"
assert cf.field_type == "select"
assert len(cf.options) == 3
async def test_custom_field_definition_invalid_type_raises(self):
"""CustomFieldDefinition rejects invalid field_type."""
from app.plugins.manifest import CustomFieldDefinition
from pydantic import ValidationError
with pytest.raises(ValidationError):
CustomFieldDefinition(
name="bad_field",
field_type="invalid_type",
)
async def test_plugin_manifest_with_custom_fields(self):
"""PluginManifest accepts custom_fields list."""
from app.plugins.manifest import PluginManifest, CustomFieldDefinition
manifest = PluginManifest(
name="test_plugin",
version="1.0.0",
display_name="Test Plugin",
custom_fields=[
CustomFieldDefinition(
name="score",
label="Score",
field_type="number",
entity="contact",
),
],
)
assert len(manifest.custom_fields) == 1
assert manifest.custom_fields[0].name == "score"
+128
View File
@@ -0,0 +1,128 @@
"""Saved filters tests — CRUD for reusable filter criteria."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
class TestSavedFilterList:
"""GET /api/v1/saved-filters"""
async def test_list_saved_filters_returns_200(self, client: AsyncClient, db_session):
"""GET /saved-filters returns 200 with list."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
async def test_list_saved_filters_with_entity_type(self, client: AsyncClient, db_session):
"""GET /saved-filters?entity_type=contacts filters by entity."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get("/api/v1/saved-filters?entity_type=contacts", headers=ORIGIN_HEADER)
assert resp.status_code == 200
for item in resp.json():
assert item["entity_type"] == "contacts"
async def test_list_saved_filters_requires_auth(self, client: AsyncClient, db_session):
"""GET /saved-filters without auth returns 401."""
resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
assert resp.status_code == 401
@pytest.mark.asyncio
class TestSavedFilterCreate:
"""POST /api/v1/saved-filters"""
async def test_create_saved_filter_returns_201(self, client: AsyncClient, db_session):
"""POST /saved-filters creates a filter and returns 201."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/saved-filters",
json={
"name": "Important Clients",
"entity_type": "contacts",
"filter_criteria": {"type": "company", "search": "important"},
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Important Clients"
assert data["entity_type"] == "contacts"
assert data["filter_criteria"]["type"] == "company"
async def test_create_saved_filter_duplicate_returns_409(self, client: AsyncClient, db_session):
"""POST /saved-filters with duplicate name returns 409."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
# Create first
await client.post(
"/api/v1/saved-filters",
json={"name": "My Filter", "entity_type": "contacts", "filter_criteria": {}},
headers=ORIGIN_HEADER,
)
# Create duplicate
resp = await client.post(
"/api/v1/saved-filters",
json={"name": "My Filter", "entity_type": "contacts", "filter_criteria": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 409
async def test_create_saved_filter_invalid_entity_returns_422(self, client: AsyncClient, db_session):
"""POST /saved-filters with invalid entity_type returns 422."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/saved-filters",
json={"name": "Test", "entity_type": "invalid", "filter_criteria": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
@pytest.mark.asyncio
class TestSavedFilterDelete:
"""DELETE /api/v1/saved-filters/{id}"""
async def test_delete_saved_filter_returns_204(self, client: AsyncClient, db_session):
"""DELETE /saved-filters/{id} soft-deletes the filter."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
# Create
create_resp = await client.post(
"/api/v1/saved-filters",
json={"name": "To Delete", "entity_type": "contacts", "filter_criteria": {}},
headers=ORIGIN_HEADER,
)
filter_id = create_resp.json()["id"]
# Delete
resp = await client.delete(f"/api/v1/saved-filters/{filter_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 204
# Verify gone from list
list_resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
assert not any(f["id"] == filter_id for f in list_resp.json())
async def test_delete_saved_filter_not_found_returns_404(self, client: AsyncClient, db_session):
"""DELETE non-existent filter returns 404."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.delete(
"/api/v1/saved-filters/00000000-0000-0000-0000-000000000000",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
async def test_delete_saved_filter_invalid_uuid_returns_400(self, client: AsyncClient, db_session):
"""DELETE with invalid UUID returns 400."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.delete("/api/v1/saved-filters/not-a-uuid", headers=ORIGIN_HEADER)
assert resp.status_code == 400
+180
View File
@@ -0,0 +1,180 @@
"""Tasks plugin tests — CRUD, assign, status update, filtering."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
class TestTaskList:
"""GET /api/v1/tasks"""
async def test_list_tasks_returns_200(self, client: AsyncClient, db_session):
"""GET /tasks returns 200 with paginated list."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get("/api/v1/tasks", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "page_size" in data
async def test_list_tasks_with_status_filter(self, client: AsyncClient, db_session):
"""GET /tasks?status=open filters by status."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get("/api/v1/tasks?status=open", headers=ORIGIN_HEADER)
assert resp.status_code == 200
for item in resp.json()["items"]:
assert item["status"] == "open"
async def test_list_tasks_requires_auth(self, client: AsyncClient, db_session):
"""GET /tasks without auth returns 401."""
resp = await client.get("/api/v1/tasks", headers=ORIGIN_HEADER)
assert resp.status_code == 401
@pytest.mark.asyncio
class TestTaskCreate:
"""POST /api/v1/tasks"""
async def test_create_task_returns_201(self, client: AsyncClient, db_session):
"""POST /tasks creates a task and returns 201."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/tasks",
json={"title": "Call customer", "priority": "high"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["title"] == "Call customer"
assert data["priority"] == "high"
assert data["status"] == "open"
async def test_create_task_with_due_date(self, client: AsyncClient, db_session):
"""POST /tasks with due_date stores it correctly."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/tasks",
json={"title": "Follow up", "due_date": "2025-12-31T10:00:00Z"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
assert resp.json()["due_date"] is not None
async def test_create_task_empty_title_returns_422(self, client: AsyncClient, db_session):
"""POST /tasks with empty title returns 422."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/tasks",
json={"title": ""},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
@pytest.mark.asyncio
class TestTaskUpdate:
"""PATCH /api/v1/tasks/{id}"""
async def test_update_task_returns_200(self, client: AsyncClient, db_session):
"""PATCH /tasks/{id} updates the task."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
# Create
create_resp = await client.post(
"/api/v1/tasks",
json={"title": "Original"},
headers=ORIGIN_HEADER,
)
task_id = create_resp.json()["id"]
# Update
resp = await client.patch(
f"/api/v1/tasks/{task_id}",
json={"title": "Updated", "status": "in_progress"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["title"] == "Updated"
assert resp.json()["status"] == "in_progress"
async def test_update_task_not_found_returns_404(self, client: AsyncClient, db_session):
"""PATCH non-existent task returns 404."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.patch(
"/api/v1/tasks/00000000-0000-0000-0000-000000000000",
json={"title": "Updated"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
class TestTaskStatus:
"""POST /api/v1/tasks/{id}/status"""
async def test_update_status_returns_200(self, client: AsyncClient, db_session):
"""POST /tasks/{id}/status updates status."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
create_resp = await client.post(
"/api/v1/tasks",
json={"title": "Task to complete"},
headers=ORIGIN_HEADER,
)
task_id = create_resp.json()["id"]
resp = await client.post(
f"/api/v1/tasks/{task_id}/status",
json={"status": "done"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["status"] == "done"
async def test_update_status_invalid_returns_422(self, client: AsyncClient, db_session):
"""POST /tasks/{id}/status with invalid status returns 422."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
create_resp = await client.post(
"/api/v1/tasks",
json={"title": "Task"},
headers=ORIGIN_HEADER,
)
task_id = create_resp.json()["id"]
resp = await client.post(
f"/api/v1/tasks/{task_id}/status",
json={"status": "invalid"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
@pytest.mark.asyncio
class TestTaskDelete:
"""DELETE /api/v1/tasks/{id}"""
async def test_delete_task_returns_204(self, client: AsyncClient, db_session):
"""DELETE /tasks/{id} soft-deletes the task."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
create_resp = await client.post(
"/api/v1/tasks",
json={"title": "To delete"},
headers=ORIGIN_HEADER,
)
task_id = create_resp.json()["id"]
resp = await client.delete(f"/api/v1/tasks/{task_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 204
# Verify it's gone from list
list_resp = await client.get("/api/v1/tasks", headers=ORIGIN_HEADER)
assert not any(t["id"] == task_id for t in list_resp.json()["items"])