Phase 1C: Frontend unified contact UI

This commit is contained in:
Agent Zero
2026-07-23 17:17:32 +02:00
parent a8331fbc2b
commit 879106c4eb
62 changed files with 552 additions and 1276 deletions
+1 -1
View File
@@ -142,7 +142,7 @@ async def deep_analysis(
# All mails for company
mail_result = await db.execute(
select(Mail)
.where(Mail.company_id == eid)
.where(Mail.contact_id == eid)
.where(Mail.tenant_id == tid)
.order_by(Mail.received_at.desc())
.limit(30)
@@ -260,10 +260,10 @@ async def gather_context(
)
context["thread"] = [_serialize_row(m) for m in thread_result.scalars().all()]
if mail and mail.company_id:
if mail and mail.contact_id:
comp_result = await db.execute(
select(Company)
.where(Company.id == mail.company_id)
.where(Company.id == mail.contact_id)
.where(Company.tenant_id == tenant_id)
.limit(1)
)
@@ -307,7 +307,7 @@ async def gather_context(
mail_result = await db.execute(
select(Mail)
.where(Mail.company_id == entity_id)
.where(Mail.contact_id == entity_id)
.where(Mail.tenant_id == tenant_id)
.order_by(Mail.received_at.desc())
.limit(10)
@@ -321,7 +321,7 @@ async def gather_context(
event_result = await db.execute(
select(CalendarEntry)
.join(CalendarEntryLink, CalendarEntryLink.entry_id == CalendarEntry.id)
.where(CalendarEntryLink.entity_type == "company")
.where(CalendarEntryLink.entity_type == "contact")
.where(CalendarEntryLink.entity_id == entity_id)
.where(CalendarEntry.tenant_id == tenant_id)
.where(CalendarEntry.start_at > now)
+1 -1
View File
@@ -117,7 +117,7 @@ class EntryResponse(BaseModel):
class LinkRequest(BaseModel):
entity_type: str = Field(..., pattern="^(company|contact)$")
entity_type: str = Field(..., pattern="^contact$")
entity_id: str
+1 -31
View File
@@ -23,18 +23,13 @@ class EntityLinksPlugin(BasePlugin):
module="app.plugins.builtins.entity_links.routes",
router_attr="router",
),
PluginRouteDef(
path="/api/v1/companies",
module="app.plugins.builtins.entity_links.routes",
router_attr="company_router",
),
PluginRouteDef(
path="/api/v1/contacts",
module="app.plugins.builtins.entity_links.routes",
router_attr="contact_router",
),
],
events=["company.deleted", "contact.deleted"],
events=["contact.deleted"],
migrations=["0001_initial.sql"],
permissions=[
"entity_links:read",
@@ -44,31 +39,6 @@ class EntityLinksPlugin(BasePlugin):
is_core=True,
)
async def on_company_deleted(self, payload: dict[str, Any]) -> None:
"""Handle company.deleted event — remove all EntityLink rows for that company."""
from sqlalchemy import delete
from app.core.db import get_session_factory
from app.plugins.builtins.entity_links.models import EntityLink
entity_id = payload.get("entity_id") or payload.get("company_id")
tenant_id = payload.get("tenant_id")
if entity_id is None or tenant_id is None:
return
import uuid as _uuid
factory = get_session_factory()
async with factory() as session:
await session.execute(
delete(EntityLink).where(
EntityLink.tenant_id == _uuid.UUID(tenant_id),
EntityLink.entity_type == "company",
EntityLink.entity_id == _uuid.UUID(str(entity_id)),
)
)
await session.commit()
async def on_contact_deleted(self, payload: dict[str, Any]) -> None:
"""Handle contact.deleted event — remove all EntityLink rows for that contact."""
from sqlalchemy import delete
+2 -32
View File
@@ -14,10 +14,9 @@ from app.plugins.builtins.entity_links.models import EntityLink
from app.plugins.builtins.entity_links.schemas import EntityLinkRequest
router = APIRouter(prefix="/api/v1/dms", tags=["entity-links"])
company_router = APIRouter(prefix="/api/v1/companies", tags=["entity-links"])
contact_router = APIRouter(prefix="/api/v1/contacts", tags=["entity-links"])
VALID_ENTITY_TYPES = {"company", "contact"}
VALID_ENTITY_TYPES = {"contact"}
def _parse_uuid(val: str, field: str) -> uuid.UUID:
@@ -36,7 +35,7 @@ async def link_file_to_entity(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Link a file to a company or contact."""
"""Link a file to a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
fid = _parse_uuid(file_id, "file_id")
@@ -140,35 +139,6 @@ async def list_file_links(
]
@company_router.get("/{company_id}/files", dependencies=[Depends(require_permission("entity_links:read"))])
async def list_company_files(
company_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all files linked to a company (reverse link)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
cid = _parse_uuid(company_id, "company_id")
result = await db.execute(
select(EntityLink).where(
EntityLink.tenant_id == tenant_id,
EntityLink.entity_type == "company",
EntityLink.entity_id == cid,
)
)
links = result.scalars().all()
return [
{
"id": str(link.id),
"file_id": str(link.file_id),
"entity_type": link.entity_type,
"entity_id": str(link.entity_id),
}
for link in links
]
@contact_router.get("/{contact_id}/files", dependencies=[Depends(require_permission("entity_links:read"))])
async def list_contact_files(
contact_id: str,
+1 -1
View File
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
class EntityLinkRequest(BaseModel):
entity_type: str = Field(..., pattern="^(company|contact)$")
entity_type: str = Field(..., pattern="^contact$")
entity_id: str
-1
View File
@@ -141,7 +141,6 @@ class Mail(Base, TenantMixin):
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
contact_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
company_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
-3
View File
@@ -1451,13 +1451,10 @@ async def link_mail(
await _check_delegate_access(db, account, user_id, "write")
if data.contact_id:
mail.contact_id = _parse_uuid(data.contact_id, "contact_id")
if data.company_id:
mail.company_id = _parse_uuid(data.company_id, "company_id")
await db.flush()
return {
"linked": True,
"contact_id": str(mail.contact_id) if mail.contact_id else None,
"company_id": str(mail.company_id) if mail.company_id else None,
}
-2
View File
@@ -176,7 +176,6 @@ class MailResponse(BaseModel):
received_at: datetime | None = None
sent_at: datetime | None = None
contact_id: str | None = None
company_id: str | None = None
attachments: list[dict] = Field(default_factory=list)
labels: list[dict] = Field(default_factory=list)
@@ -190,7 +189,6 @@ class MailListResponse(BaseModel):
class MailLinkRequest(BaseModel):
contact_id: str | None = None
company_id: str | None = None
class MailMoveRequest(BaseModel):
-1
View File
@@ -2032,7 +2032,6 @@ def mail_to_response(
"received_at": mail.received_at,
"sent_at": mail.sent_at,
"contact_id": str(mail.contact_id) if mail.contact_id else None,
"company_id": str(mail.company_id) if mail.company_id else None,
"attachments": [],
"labels": [],
}
+1 -1
View File
@@ -21,7 +21,7 @@ from app.plugins.builtins.tags.schemas import (
router = APIRouter(prefix="/api/v1/tags", tags=["tags"])
VALID_ENTITY_TYPES = {"company", "contact", "file", "folder"}
VALID_ENTITY_TYPES = {"contact", "file", "folder"}
def _parse_uuid(val: str, field: str) -> uuid.UUID:
+3 -3
View File
@@ -24,19 +24,19 @@ class TagResponse(BaseModel):
class TagAssignRequest(BaseModel):
tag_id: str
entity_type: str = Field(..., pattern="^(company|contact|file|folder)$")
entity_type: str = Field(..., pattern="^(contact|file|folder)$")
entity_id: str
class TagUnassignRequest(BaseModel):
tag_id: str
entity_type: str = Field(..., pattern="^(company|contact|file|folder)$")
entity_type: str = Field(..., pattern="^(contact|file|folder)$")
entity_id: str
class TagBulkAssignRequest(BaseModel):
tag_ids: list[str] = Field(..., min_length=1)
entity_type: str = Field(..., pattern="^(company|contact|file|folder)$")
entity_type: str = Field(..., pattern="^(contact|file|folder)$")
entity_id: str
+1 -4
View File
@@ -18,7 +18,7 @@ class TestSamplePlugin(BasePlugin):
description="A sample plugin for testing install/activate/deactivate/uninstall lifecycle.",
dependencies=[],
routes=[],
events=["company.created", "contact.created"],
events=["contact.created"],
migrations=["0001_test_plugin.sql"],
permissions=[],
)
@@ -45,8 +45,5 @@ class TestSamplePlugin(BasePlugin):
async def on_uninstall(self, db, service_container) -> None:
self.uninstall_called = True
async def on_company_created(self, payload: dict[str, Any]) -> None:
self.event_log.append({"event": "company.created", "payload": payload})
async def on_contact_created(self, payload: dict[str, Any]) -> None:
self.event_log.append({"event": "contact.created", "payload": payload})
@@ -181,7 +181,6 @@ async def index_entity(
table_map = {
"contact": "contacts",
"company": "contacts",
"mail": "mails",
"file": "files",
"event": "calendar_entries",
+2 -4
View File
@@ -122,7 +122,7 @@ async def index_contact(ctx: dict[str, Any], contact_id: str) -> None:
async def index_company(ctx: dict[str, Any], company_id: str) -> None:
"""Index a company: generate and store embedding."""
"""Index a company (contact with type='company'): generate and store embedding."""
from sqlalchemy import text
from app.plugins.builtins.unified_search.embedding import index_entity
@@ -137,7 +137,7 @@ async def index_company(ctx: dict[str, Any], company_id: str) -> None:
row = result.mappings().first()
if not row:
return
await index_entity("company", eid, row["tenant_id"], db)
await index_entity("contact", eid, row["tenant_id"], db)
except Exception:
logger.exception("Failed to index company %s", company_id)
@@ -170,7 +170,6 @@ async def reindex(ctx: dict[str, Any], entity_type: str) -> None:
table_map = {
"contact": "contacts",
"company": "contacts",
"mail": "mails",
"file": "files",
"event": "calendar_entries",
@@ -216,7 +215,6 @@ async def embedding_batch(ctx: dict[str, Any]) -> None:
table_map = {
"contact": "contacts",
"company": "contacts",
"mail": "mails",
"file": "files",
"event": "calendar_entries",
@@ -35,8 +35,6 @@ class UnifiedSearchPlugin(BasePlugin):
"file.uploaded",
"contact.created",
"contact.updated",
"company.created",
"company.updated",
"calendar.entry.created",
],
migrations=["0001_initial.sql", "0002_embeddings.sql"],
@@ -97,20 +95,6 @@ class UnifiedSearchPlugin(BasePlugin):
if contact_id:
await enqueue_job("index_contact", contact_id)
async def on_company_created(self, payload: dict[str, Any]) -> None:
from app.core.jobs import enqueue_job
company_id = payload.get("company_id")
if company_id:
await enqueue_job("index_company", company_id)
async def on_company_updated(self, payload: dict[str, Any]) -> None:
from app.core.jobs import enqueue_job
company_id = payload.get("company_id")
if company_id:
await enqueue_job("index_company", company_id)
async def on_calendar_entry_created(self, payload: dict[str, Any]) -> None:
from app.core.jobs import enqueue_job
@@ -103,9 +103,6 @@ async def auto_register_providers(db: AsyncSession) -> None:
from app.plugins.builtins.unified_search.providers.contact_provider import (
ContactSearchProvider,
)
from app.plugins.builtins.unified_search.providers.company_provider import (
CompanySearchProvider,
)
from app.plugins.builtins.unified_search.providers.mail_provider import (
MailSearchProvider,
)
@@ -122,7 +119,6 @@ async def auto_register_providers(db: AsyncSession) -> None:
# Register all built-in providers
for provider_cls in [
ContactSearchProvider,
CompanySearchProvider,
MailSearchProvider,
FileSearchProvider,
EventSearchProvider,
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
class CompanySearchProvider:
"""Search provider for Company entities (contacts with type='company')."""
entity_type = "company"
entity_type = "contact"
async def search_fts(
self,
@@ -117,5 +117,5 @@ class CompanySearchProvider:
"title": name,
"snippet": email[:200],
"score": 0.0,
"data": {},
"data": {"type": "company"},
}
@@ -70,7 +70,6 @@ async def search(
# Map entity_type to module name for field-level permissions
_ENTITY_TO_MODULE = {
"contact": "contacts",
"company": "contacts",
"mail": "mail",
"file": "dms",
"event": "calendar",
@@ -17,7 +17,6 @@ logger = logging.getLogger(__name__)
# Entity type -> (table_name, tsv_column, embedding_column)
SEARCHABLE_ENTITIES: dict[str, tuple[str, str, str]] = {
"contact": ("contacts", "search_tsv", "embedding"),
"company": ("companies", "search_tsv", "embedding"),
"mail": ("mails", "body_tsv", "embedding"),
"file": ("files", "content_tsv", "embedding"),
"event": ("calendar_entries", "search_tsv", "embedding"),