+ {[
+ { value: 'all' as ContactFilter, label: t('common.all') },
+ { value: 'company' as ContactFilter, label: t('contacts.companies') },
+ { value: 'person' as ContactFilter, label: t('contacts.persons') },
+ ].map((opt) => (
+
+ ))}
+
+
{/* View mode toggle */}
{viewModeOptions.map((opt) => (
diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx
index 5ab8d34..69909a0 100644
--- a/frontend/src/routes/index.tsx
+++ b/frontend/src/routes/index.tsx
@@ -10,6 +10,7 @@ import { Loader2 } from 'lucide-react';
// Lazy-loaded pages (code-splitting)
const DashboardPage = React.lazy(() => import('@/pages/Dashboard').then(m => ({ default: m.DashboardPage })));
const ContactsListPage = React.lazy(() => import('@/pages/ContactsList').then(m => ({ default: m.ContactsListPage })));
+const ContactDetailPage = React.lazy(() => import('@/pages/ContactDetailPage').then(m => ({ default: m.ContactDetailPage })));
const AuditLogPage = React.lazy(() => import('@/pages/AuditLog').then(m => ({ default: m.AuditLogPage })));
const GlobalSearchResultsPage = React.lazy(() => import('@/pages/GlobalSearchResults').then(m => ({ default: m.GlobalSearchResultsPage })));
const SettingsPage = React.lazy(() => import('@/pages/Settings').then(m => ({ default: m.SettingsPage })));
@@ -71,6 +72,7 @@ const router = createBrowserRouter([
{ path: '/', element: withSuspense(
) },
{ path: '/dashboard', element: withSuspense(
) },
{ path: '/contacts', element: withSuspense(
) },
+ { path: '/contacts/:id', element: withSuspense(
) },
{ path: '/audit-log', element: withSuspense(
) },
{ path: '/search', element: withSuspense(
) },
{ path: '/calendar', element: withSuspense(
) },
diff --git a/tests/conftest.py b/tests/conftest.py
index 847a846..d2870e7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -29,8 +29,8 @@ from app.core.db import Base, close_engine, reset_engine_for_testing
from app.core.service_container import get_container # noqa: F401
from app.main import create_app
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
-from app.models.company import Company
-from app.models.contact import CompanyContact, Contact # noqa: F401
+from app.models.contact import Contact
+from app.models.contact import Contact, ContactPerson # noqa: F401
from app.models.plugin import Plugin, PluginMigration # noqa: F401
from app.models.role import Role
from app.models.tenant import Tenant
@@ -131,7 +131,7 @@ def clean_tables(db_setup):
# TRUNCATE all tables with CASCADE — fast and reliable isolation
conn.execute(
text(
- "TRUNCATE TABLE contact_pgp_keys, pgp_keys, mail_account_send_permissions, mail_account_delegates, mail_seen_by, vacation_sent_log, mail_signatures, mail_templates, mail_rules, mail_label_assignments, mail_labels, mail_attachments, mails, mail_folders, mail_accounts, resource_bookings, resources, subtasks, user_calendar_visibility, calendar_shares, calendar_entry_links, calendar_entries, calendars, files, folders, entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"
+ "TRUNCATE TABLE contact_pgp_keys, pgp_keys, mail_account_send_permissions, mail_account_delegates, mail_seen_by, vacation_sent_log, mail_signatures, mail_templates, mail_rules, mail_label_assignments, mail_labels, mail_attachments, mails, mail_folders, mail_accounts, resource_bookings, resources, subtasks, user_calendar_visibility, calendar_shares, calendar_entry_links, calendar_entries, calendars, files, folders, entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, user_tenants, users, tenants CASCADE;"
)
)
conn.commit()
@@ -268,7 +268,7 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
await db.flush()
# Create a company in tenant A
- company_a = Company(
+ company_a = Contact(
tenant_id=tenant_a.id,
name="Company Alpha",
industry="IT",
@@ -276,7 +276,7 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
updated_by=admin_a.id,
)
# Create a company in tenant B
- company_b = Company(
+ company_b = Contact(
tenant_id=tenant_b.id,
name="Company Beta",
industry="Finance",
diff --git a/tests/test_ai_proactive.py b/tests/test_ai_proactive.py
index 554be86..5bd513e 100644
--- a/tests/test_ai_proactive.py
+++ b/tests/test_ai_proactive.py
@@ -1012,7 +1012,7 @@ async def test_gather_context_mail(db_session: AsyncSession):
@pytest.mark.asyncio
async def test_gather_context_company(db_session: AsyncSession):
"""gather_context for company collects data."""
- from app.models.company import Company
+ from app.models.contact import Contact as Company
from app.models.tenant import Tenant
from app.models.user import User
from app.core.auth import hash_password
@@ -1041,8 +1041,8 @@ async def test_gather_context_company(db_session: AsyncSession):
db_session.add(company)
await db_session.commit()
- context = await gather_context(db_session, "company", company.id, tenant.id)
- assert context["entity_type"] == "company"
+ context = await gather_context(db_session, "contact", company.id, tenant.id)
+ assert context["entity_type"] == "contact"
assert context["entity_id"] == str(company.id)
assert "company" in context
assert "contacts" in context
@@ -1463,7 +1463,7 @@ async def test_suggestions_filter_by_entity_type(
s_company = ProactiveSuggestion(
tenant_id=seed["tenant_a"].id,
user_id=seed["admin_a"].id,
- entity_type="company",
+ entity_type="contact",
entity_id=uuid.uuid4(),
suggestion_type="info",
title="Company Suggestion",
diff --git a/tests/test_calendar.py b/tests/test_calendar.py
index 7adc266..8841173 100644
--- a/tests/test_calendar.py
+++ b/tests/test_calendar.py
@@ -428,12 +428,12 @@ async def test_ac14_link_entry(calendar_authed_client):
company_id = str(seed["company_a"].id)
resp = await client.post(
f"/api/v1/calendar/entries/{entry_id}/link",
- json={"entity_type": "company", "entity_id": company_id},
+ json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
- assert data["entity_type"] == "company"
+ assert data["entity_type"] == "contact"
assert data["entity_id"] == company_id
diff --git a/tests/test_entity_links.py b/tests/test_entity_links.py
index ac7d0c3..466dad2 100644
--- a/tests/test_entity_links.py
+++ b/tests/test_entity_links.py
@@ -66,13 +66,13 @@ async def test_link_file_to_company(authed_client: AsyncClient):
resp = await client.post(
f"/api/v1/dms/files/{file_id}/link",
- json={"entity_type": "company", "entity_id": company_id},
+ json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["file_id"] == file_id
- assert data["entity_type"] == "company"
+ assert data["entity_type"] == "contact"
assert data["entity_id"] == company_id
assert data["already_linked"] is False
@@ -106,7 +106,7 @@ async def test_unlink_file_from_entity(authed_client: AsyncClient):
# Link first
resp = await client.post(
f"/api/v1/dms/files/{file_id}/link",
- json={"entity_type": "company", "entity_id": company_id},
+ json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
@@ -115,7 +115,7 @@ async def test_unlink_file_from_entity(authed_client: AsyncClient):
resp = await client.request(
"DELETE",
f"/api/v1/dms/files/{file_id}/link",
- json={"entity_type": "company", "entity_id": company_id},
+ json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 204
@@ -137,7 +137,7 @@ async def test_list_file_links(authed_client: AsyncClient):
# Link to company
await client.post(
f"/api/v1/dms/files/{file_id}/link",
- json={"entity_type": "company", "entity_id": company_id},
+ json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
# Link to contact
@@ -164,7 +164,7 @@ async def test_multi_links_one_file_many_entities(authed_client: AsyncClient):
entity_id = str(uuid.uuid4())
resp = await client.post(
f"/api/v1/dms/files/{file_id}/link",
- json={"entity_type": "company", "entity_id": entity_id},
+ json={"entity_type": "contact", "entity_id": entity_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
@@ -228,7 +228,7 @@ async def test_event_cleanup_on_company_deleted(authed_client: AsyncClient):
file_id = str(uuid.uuid4())
resp = await client.post(
f"/api/v1/dms/files/{file_id}/link",
- json={"entity_type": "company", "entity_id": str(company_id)},
+ json={"entity_type": "contact", "entity_id": str(company_id)},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
@@ -309,7 +309,7 @@ async def test_link_invalid_file_id(authed_client: AsyncClient):
client, seed = authed_client
resp = await client.post(
"/api/v1/dms/files/bad-uuid/link",
- json={"entity_type": "company", "entity_id": str(uuid.uuid4())},
+ json={"entity_type": "contact", "entity_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@@ -321,7 +321,7 @@ async def test_link_invalid_entity_id(authed_client: AsyncClient):
client, seed = authed_client
resp = await client.post(
f"/api/v1/dms/files/{uuid.uuid4()}/link",
- json={"entity_type": "company", "entity_id": "bad-uuid"},
+ json={"entity_type": "contact", "entity_id": "bad-uuid"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@@ -347,14 +347,14 @@ async def test_link_already_linked(authed_client: AsyncClient):
company_id = str(seed["company_a"].id)
resp = await client.post(
f"/api/v1/dms/files/{file_id}/link",
- json={"entity_type": "company", "entity_id": company_id},
+ json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["already_linked"] is False
resp = await client.post(
f"/api/v1/dms/files/{file_id}/link",
- json={"entity_type": "company", "entity_id": company_id},
+ json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
@@ -370,7 +370,7 @@ async def test_unlink_not_found(authed_client: AsyncClient):
resp = await client.request(
"DELETE",
f"/api/v1/dms/files/{file_id}/link",
- json={"entity_type": "company", "entity_id": company_id},
+ json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@@ -383,7 +383,7 @@ async def test_unlink_invalid_file_id(authed_client: AsyncClient):
resp = await client.request(
"DELETE",
"/api/v1/dms/files/bad-uuid/link",
- json={"entity_type": "company", "entity_id": str(uuid.uuid4())},
+ json={"entity_type": "contact", "entity_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
diff --git a/tests/test_tags.py b/tests/test_tags.py
index 5aaab9c..ed30b5d 100644
--- a/tests/test_tags.py
+++ b/tests/test_tags.py
@@ -164,13 +164,13 @@ async def test_assign_tag(authed_client: AsyncClient):
resp = await authed_client.post(
"/api/v1/tags/assign",
- json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
+ json={"tag_id": tag_id, "entity_type": "contact", "entity_id": entity_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["tag_id"] == tag_id
- assert data["entity_type"] == "company"
+ assert data["entity_type"] == "contact"
assert data["entity_id"] == entity_id
assert data["already_assigned"] is False
@@ -377,7 +377,7 @@ async def test_assign_tag_not_found(authed_client: AsyncClient):
"/api/v1/tags/assign",
json={
"tag_id": str(uuid.uuid4()),
- "entity_type": "company",
+ "entity_type": "contact",
"entity_id": str(uuid.uuid4()),
},
headers=ORIGIN_HEADER,
@@ -397,14 +397,14 @@ async def test_assign_tag_already_assigned(authed_client: AsyncClient):
entity_id = str(uuid.uuid4())
resp = await authed_client.post(
"/api/v1/tags/assign",
- json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
+ json={"tag_id": tag_id, "entity_type": "contact", "entity_id": entity_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["already_assigned"] is False
resp = await authed_client.post(
"/api/v1/tags/assign",
- json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
+ json={"tag_id": tag_id, "entity_type": "contact", "entity_id": entity_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
@@ -416,7 +416,7 @@ async def test_assign_tag_invalid_ids(authed_client: AsyncClient):
"""POST /api/v1/tags/assign with invalid tag_id → 400."""
resp = await authed_client.post(
"/api/v1/tags/assign",
- json={"tag_id": "bad", "entity_type": "company", "entity_id": str(uuid.uuid4())},
+ json={"tag_id": "bad", "entity_type": "contact", "entity_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@@ -434,7 +434,7 @@ async def test_unassign_tag_not_found(authed_client: AsyncClient):
resp = await authed_client.request(
"DELETE",
"/api/v1/tags/assign",
- json={"tag_id": tag_id, "entity_type": "company", "entity_id": str(uuid.uuid4())},
+ json={"tag_id": tag_id, "entity_type": "contact", "entity_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
diff --git a/tests/test_unified_search.py b/tests/test_unified_search.py
index 36a9550..e39eeb9 100644
--- a/tests/test_unified_search.py
+++ b/tests/test_unified_search.py
@@ -167,7 +167,7 @@ async def test_ac1_search_returns_results(
client, _ = search_authed_client
mock_hybrid_search.return_value = [
{
- "entity_type": "company",
+ "entity_type": "contact",
"entity_id": str(uuid.uuid4()),
"title": "Company Alpha",
"snippet": "IT company",
@@ -186,7 +186,7 @@ async def test_ac1_search_returns_results(
assert "results" in data
assert isinstance(data["results"], list)
assert len(data["results"]) > 0
- assert data["results"][0]["entity_type"] == "company"
+ assert data["results"][0]["entity_type"] == "contact"
assert data["results"][0]["title"] == "Company Alpha"
assert "score" in data["results"][0]
assert "query" in data
@@ -209,13 +209,13 @@ async def test_ac2_search_entity_types_filter(
resp = await client.post(
"/api/v1/search",
- json={"query": "test", "entity_types": ["company"]},
+ json={"query": "test", "entity_types": ["contact"]},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
- # Verify hybrid_search was called with entity_types=["company"]
+ # Verify hybrid_search was called with entity_types=["contact"]
call_kwargs = mock_hybrid_search.call_args
- assert call_kwargs.kwargs.get("entity_types") == ["company"]
+ assert call_kwargs.kwargs.get("entity_types") == ["contact"]
# ─── AC3: No auth → 401 ───
@@ -322,7 +322,7 @@ async def test_ac8_similar_returns_results(
resp = await client.post(
"/api/v1/search/similar",
json={
- "entity_type": "company",
+ "entity_type": "contact",
"entity_id": str(seed["company_a"].id),
"limit": 5,
},
@@ -347,7 +347,7 @@ async def test_ac9_similar_tenant_isolation(
resp = await client.post(
"/api/v1/search/similar",
json={
- "entity_type": "company",
+ "entity_type": "contact",
"entity_id": str(seed["company_b"].id),
"limit": 5,
},
@@ -398,7 +398,7 @@ async def test_ac11_reindex_admin_200(
client, _ = search_authed_client
resp = await client.post(
"/api/v1/search/reindex",
- json={"entity_types": ["company"]},
+ json={"entity_types": ["contact"]},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
@@ -420,7 +420,7 @@ async def test_ac12_reindex_viewer_403(
resp = await search_client.post(
"/api/v1/search/reindex",
- json={"entity_types": ["company"]},
+ json={"entity_types": ["contact"]},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 403
@@ -440,7 +440,7 @@ def test_rrf_fusion_combines_fts_and_vector_scores():
{"id": "3", "title": "Result C", "score": 0.7},
]
- fused = rrf_fusion(fts_results, vec_results, "company")
+ fused = rrf_fusion(fts_results, vec_results, "contact")
# All 3 unique IDs should be present
ids = [str(r.get("id", "")) for r in fused]
@@ -457,7 +457,7 @@ def test_rrf_fusion_combines_fts_and_vector_scores():
def test_rrf_fusion_empty_inputs():
"""RRF fusion with empty inputs returns empty list."""
- fused = rrf_fusion([], [], "company")
+ fused = rrf_fusion([], [], "contact")
assert fused == []
@@ -467,7 +467,7 @@ def test_rrf_fusion_fts_only():
{"id": "1", "title": "A"},
{"id": "2", "title": "B"},
]
- fused = rrf_fusion(fts_results, [], "company")
+ fused = rrf_fusion(fts_results, [], "contact")
assert len(fused) == 2
# First result (rank 0) should have higher score
assert fused[0]["_score"] > fused[1]["_score"]
@@ -547,7 +547,7 @@ def test_provider_get_all():
p1 = MagicMock()
p1.entity_type = "contact"
p2 = MagicMock()
- p2.entity_type = "company"
+ p2.entity_type = "contact"
registry.register(p1)
registry.register(p2)
@@ -556,7 +556,6 @@ def test_provider_get_all():
assert len(all_providers) == 2
entity_types = [p.entity_type for p in all_providers]
assert "contact" in entity_types
- assert "company" in entity_types
def test_provider_clear():
@@ -577,13 +576,12 @@ def test_provider_get_entity_types():
p1 = MagicMock()
p1.entity_type = "contact"
p2 = MagicMock()
- p2.entity_type = "company"
+ p2.entity_type = "contact"
registry.register(p1)
registry.register(p2)
types = registry.get_entity_types()
assert "contact" in types
- assert "company" in types
assert len(types) == 2
@@ -745,12 +743,12 @@ async def test_llm_aggregate_results_success():
mock_agg_resp.choices = [MagicMock()]
mock_agg_resp.choices[0].message.content = json.dumps({
"summary": "2 Ergebnisse gefunden",
- "facets": {"types": {"company": 1, "contact": 1}},
- "suggestions": ["Filter by company"],
+ "facets": {"types": {"contact": 2}},
+ "suggestions": ["Filter by contact"],
})
with patch("litellm.acompletion", new_callable=AsyncMock, return_value=mock_agg_resp):
results = [
- {"entity_type": "company", "title": "Company Alpha"},
+ {"entity_type": "contact", "title": "Company Alpha"},
{"entity_type": "contact", "title": "John Doe"},
]
result = await llm_aggregate_results(results, "test")
@@ -997,7 +995,7 @@ async def test_index_contact(mock_index_entity, mock_factory, db_session: AsyncS
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
async def test_index_company(mock_index_entity, mock_factory, db_session: AsyncSession):
"""index_company calls index_entity."""
- from app.models.company import Company
+ from app.models.contact import Contact as Company
from app.models.tenant import Tenant
from app.models.user import User
from app.core.auth import hash_password
@@ -1038,7 +1036,7 @@ async def test_index_company(mock_index_entity, mock_factory, db_session: AsyncS
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
async def test_reindex(mock_index_entity, mock_factory, db_session: AsyncSession):
"""reindex iterates over all entities of a type."""
- from app.models.company import Company
+ from app.models.contact import Contact as Company
from app.models.tenant import Tenant
from app.models.user import User
from app.core.auth import hash_password
@@ -1070,7 +1068,7 @@ async def test_reindex(mock_index_entity, mock_factory, db_session: AsyncSession
sf = async_sessionmaker(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession)
mock_factory.return_value = sf
- await reindex({}, "company")
+ await reindex({}, "contact")
mock_index_entity.assert_called()
@@ -1079,7 +1077,7 @@ async def test_reindex(mock_index_entity, mock_factory, db_session: AsyncSession
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
async def test_embedding_batch(mock_index_entity, mock_factory, db_session: AsyncSession):
"""embedding_batch finds entities without embedding."""
- from app.models.company import Company
+ from app.models.contact import Contact as Company
from app.models.tenant import Tenant
from app.models.user import User
from app.core.auth import hash_password
@@ -1169,7 +1167,7 @@ def test_company_provider_get_embedding_text():
"""CompanyProvider get_embedding_text is a callable method."""
provider = CompanySearchProvider()
assert hasattr(provider, "get_embedding_text")
- assert provider.entity_type == "company"
+ assert provider.entity_type == "contact"
def test_mail_provider_get_embedding_text():
@@ -1210,7 +1208,7 @@ def test_company_provider_to_search_result():
provider = CompanySearchProvider()
entity = {"id": "456", "name": "Acme Corp", "description": "IT company"}
result = provider.to_search_result(entity)
- assert result["entity_type"] == "company"
+ assert result["entity_type"] == "contact"
assert result["entity_id"] == "456"
assert result["title"] == "Acme Corp"
assert "IT company" in result["snippet"]