diff --git a/app/main.py b/app/main.py index ead5400..1c7329c 100644 --- a/app/main.py +++ b/app/main.py @@ -47,6 +47,7 @@ from app.routes import ( sequences, system_settings, attachments, + custom_fields, ) @@ -304,6 +305,7 @@ 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) # ── Register plugin routes (before SPA catch-all) ────────────────── registry = get_registry() diff --git a/app/plugins/manifest.py b/app/plugins/manifest.py index 845dce2..421c549 100644 --- a/app/plugins/manifest.py +++ b/app/plugins/manifest.py @@ -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") diff --git a/app/plugins/registry.py b/app/plugins/registry.py index 3182139..c8b4851 100644 --- a/app/plugins/registry.py +++ b/app/plugins/registry.py @@ -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 diff --git a/app/routes/custom_fields.py b/app/routes/custom_fields.py new file mode 100644 index 0000000..92a3374 --- /dev/null +++ b/app/routes/custom_fields.py @@ -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} diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000..9fb73a8 Binary files /dev/null and b/dump.rdb differ diff --git a/frontend/src/__tests__/CustomFieldRenderer.test.tsx b/frontend/src/__tests__/CustomFieldRenderer.test.tsx new file mode 100644 index 0000000..21b07c8 --- /dev/null +++ b/frontend/src/__tests__/CustomFieldRenderer.test.tsx @@ -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) => ( + + ), +})); + +vi.mock('@/components/ui/Select', () => ({ + Select: ({ value, onChange, id, options, children, ...props }: any) => ( + + ), +})); + +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(); + 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( + + ); + 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( + + ); + 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(); + 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( + + ); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(3); + expect(checkboxes[0]).toBeChecked(); + }); +}); diff --git a/frontend/src/api/customFields.ts b/frontend/src/api/customFields.ts new file mode 100644 index 0000000..8728b2d --- /dev/null +++ b/frontend/src/api/customFields.ts @@ -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(`/contacts/${contactId}/custom-fields`), + enabled: !!contactId, + }); +} + +export function useUpdateCustomFields() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ contactId, values }: { contactId: string; values: Record }) => + apiPatch(`/contacts/${contactId}/custom-fields`, { values }), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['custom-fields', variables.contactId] }); + }, + }); +} diff --git a/frontend/src/components/contacts/ContactDetail.tsx b/frontend/src/components/contacts/ContactDetail.tsx index 7142b99..9fbdc75 100644 --- a/frontend/src/components/contacts/ContactDetail.tsx +++ b/frontend/src/components/contacts/ContactDetail.tsx @@ -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 (
@@ -420,11 +426,12 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe {/* Custom Fields */} - {contact.custom && Object.keys(contact.custom).length > 0 && ( + {contact.id && customFieldDefs.length > 0 && (
-
-              {JSON.stringify(contact.custom, null, 2)}
-            
+
)} diff --git a/frontend/src/components/contacts/ContactEditModal.tsx b/frontend/src/components/contacts/ContactEditModal.tsx index f9e7891..ea3a588 100644 --- a/frontend/src/components/contacts/ContactEditModal.tsx +++ b/frontend/src/components/contacts/ContactEditModal.tsx @@ -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>({}); + + React.useEffect(() => { + if (open && customFieldsData?.fields) { + const vals: Record = {}; + 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
+ {/* Custom Fields */} + {customFieldDefs.length > 0 && ( +
+

{t('contacts.customFields')}

+ ({ ...d, value: d.default_value, plugin: '' }))} + mode="edit" + values={customValues} + onChange={handleCustomFieldChange} + /> +
+ )} + {/* Actions */}
diff --git a/frontend/src/components/contacts/CustomFieldRenderer.tsx b/frontend/src/components/contacts/CustomFieldRenderer.tsx new file mode 100644 index 0000000..b84e9a5 --- /dev/null +++ b/frontend/src/components/contacts/CustomFieldRenderer.tsx @@ -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; + 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 ( +
+ {fields.map((field) => { + const label = field.label_key ? t(field.label_key) : field.label || field.name; + const value = values[field.name] ?? field.value; + return ( +
+
{label}
+
{formatValue(value, field.field_type)}
+
+ ); + })} +
+ ); + } + + return ( +
+ {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 ( +
+ onChange?.(field.name, e.target.checked)} + className="h-4 w-4 rounded border-secondary-300" + /> + +
+ ); + } + + if (field.field_type === 'select') { + return ( +
+ + { + 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} + + ))} +
+
+ ); + } + + // text, number, date + return ( +
+ + { + let val: any = e.target.value; + if (field.field_type === 'number' && val !== '') val = Number(val); + if (val === '') val = null; + onChange?.(field.name, val); + }} + /> +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx b/frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx index d13e39e..d2e0be7 100644 --- a/frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx +++ b/frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx @@ -21,6 +21,7 @@ const mockManifests: PluginUiManifest[] = [ detail_tabs: [], settings_pages: [], dashboard_widgets: [], + custom_fields: [], }, ]; diff --git a/frontend/src/components/plugins/__tests__/PluginRouteRenderer.test.tsx b/frontend/src/components/plugins/__tests__/PluginRouteRenderer.test.tsx index 29b1989..07bf9d9 100644 --- a/frontend/src/components/plugins/__tests__/PluginRouteRenderer.test.tsx +++ b/frontend/src/components/plugins/__tests__/PluginRouteRenderer.test.tsx @@ -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: [], }, ]; diff --git a/frontend/src/store/__tests__/pluginStore.test.ts b/frontend/src/store/__tests__/pluginStore.test.ts index da7a412..6b03cd6 100644 --- a/frontend/src/store/__tests__/pluginStore.test.ts +++ b/frontend/src/store/__tests__/pluginStore.test.ts @@ -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: [], }, ]; diff --git a/frontend/src/store/pluginStore.ts b/frontend/src/store/pluginStore.ts index 3791330..e568360 100644 --- a/frontend/src/store/pluginStore.ts +++ b/frontend/src/store/pluginStore.ts @@ -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((set, get) => ({ @@ -125,4 +138,11 @@ export const usePluginStore = create((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); + }, })); diff --git a/tests/test_custom_fields.py b/tests/test_custom_fields.py new file mode 100644 index 0000000..eb89587 --- /dev/null +++ b/tests/test_custom_fields.py @@ -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"