Phase 1C: Frontend unified contact UI
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": [],
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user