Phase 1C: Frontend unified contact UI
This commit is contained in:
+30
-58
@@ -11,21 +11,17 @@ from typing import Any
|
||||
|
||||
# Precompiled patterns for intent detection
|
||||
_PATTERNS = {
|
||||
"create_company": re.compile(
|
||||
r"\b(create|add|new)\b.*\b(company|firm|organization|organisation)\b", re.IGNORECASE
|
||||
"create_contact": re.compile(
|
||||
r"\b(create|add|new)\b.*\b(contact|company|firm|organization|organisation|person)\b", re.IGNORECASE
|
||||
),
|
||||
"delete_company": re.compile(r"\b(delete|remove)\b.*\b(company|firm)\b", re.IGNORECASE),
|
||||
"update_company": re.compile(
|
||||
r"\b(update|edit|modify|change)\b.*\b(company|firm)\b", re.IGNORECASE
|
||||
"delete_contact": re.compile(r"\b(delete|remove)\b.*\b(contact|company|firm)\b", re.IGNORECASE),
|
||||
"update_contact": re.compile(
|
||||
r"\b(update|edit|modify|change)\b.*\b(contact|company|firm)\b", re.IGNORECASE
|
||||
),
|
||||
"list_company": re.compile(
|
||||
r"\b(list|show|find|search|get|display)\b.*\b(compan|firm)\b", re.IGNORECASE
|
||||
),
|
||||
"list_company2": re.compile(r"\bcompan.*\b(list|all)\b", re.IGNORECASE),
|
||||
"create_contact": re.compile(r"\b(create|add|new)\b.*\b(contact|person)\b", re.IGNORECASE),
|
||||
"list_contact": re.compile(
|
||||
r"\b(list|show|find|search|get|display)\b.*\b(contact|person)\b", re.IGNORECASE
|
||||
r"\b(list|show|find|search|get|display)\b.*\b(contact|compan|firm|person)\b", re.IGNORECASE
|
||||
),
|
||||
"list_contact2": re.compile(r"\bcompan.*\b(list|all)\b", re.IGNORECASE),
|
||||
"list_workflow": re.compile(r"\b(list|show|get|display)\b.*\b(workflow)\b", re.IGNORECASE),
|
||||
"create_workflow": re.compile(r"\b(create|new|add)\b.*\b(workflow)\b", re.IGNORECASE),
|
||||
"help": re.compile(r"\b(help|what can you do|assist)\b", re.IGNORECASE),
|
||||
@@ -34,8 +30,8 @@ _PATTERNS = {
|
||||
# Name extraction patterns - using single-quoted strings to avoid escaping issues
|
||||
_NAME_PATTERNS = [
|
||||
re.compile(r"\b(?:named|called|for)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||
re.compile(r"\bcompany\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||
re.compile(r"\bcontact\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||
re.compile(r"\bcompany\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||
re.compile(r"\bworkflow\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||
re.compile(r"\bfirm\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||
re.compile(r"\bperson\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE),
|
||||
@@ -63,28 +59,28 @@ def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> l
|
||||
q = query.lower().strip()
|
||||
actions: list[dict[str, Any]] = []
|
||||
|
||||
# --- Company intents ---
|
||||
if _PATTERNS["create_company"].search(q):
|
||||
# --- Contact intents (unified: company + person) ---
|
||||
if _PATTERNS["create_contact"].search(q):
|
||||
name = _extract_name(query)
|
||||
actions.append(
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/companies",
|
||||
"body": {"name": name or "New Company"},
|
||||
"description": f"Create a new company named '{name or 'New Company'}'",
|
||||
"path": "/api/v1/contacts",
|
||||
"body": {"name": name or "New Contact", "type": "company"},
|
||||
"description": f"Create a new contact named '{name or 'New Contact'}'",
|
||||
"confidence": 0.9,
|
||||
}
|
||||
)
|
||||
|
||||
elif _PATTERNS["delete_company"].search(q):
|
||||
entity_id = context.get("company_id") or context.get("entity_id")
|
||||
elif _PATTERNS["delete_contact"].search(q):
|
||||
entity_id = context.get("contact_id") or context.get("entity_id")
|
||||
if entity_id:
|
||||
actions.append(
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": f"/api/v1/companies/{entity_id}",
|
||||
"path": f"/api/v1/contacts/{entity_id}",
|
||||
"body": None,
|
||||
"description": f"Delete company {entity_id}",
|
||||
"description": f"Delete contact {entity_id}",
|
||||
"confidence": 0.9,
|
||||
}
|
||||
)
|
||||
@@ -92,65 +88,41 @@ def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> l
|
||||
actions.append(
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/companies/{id}",
|
||||
"path": "/api/v1/contacts/{id}",
|
||||
"body": None,
|
||||
"description": "Delete a company (requires company ID in context or selection)",
|
||||
"description": "Delete a contact (requires contact ID in context or selection)",
|
||||
"confidence": 0.5,
|
||||
}
|
||||
)
|
||||
|
||||
elif _PATTERNS["update_company"].search(q):
|
||||
entity_id = context.get("company_id") or context.get("entity_id")
|
||||
path = f"/api/v1/companies/{entity_id}" if entity_id else "/api/v1/companies/{id}"
|
||||
elif _PATTERNS["update_contact"].search(q):
|
||||
entity_id = context.get("contact_id") or context.get("entity_id")
|
||||
path = f"/api/v1/contacts/{entity_id}" if entity_id else "/api/v1/contacts/{id}"
|
||||
actions.append(
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": path,
|
||||
"body": _extract_update_fields(query),
|
||||
"description": "Update company information",
|
||||
"description": "Update contact information",
|
||||
"confidence": 0.8,
|
||||
}
|
||||
)
|
||||
|
||||
elif _PATTERNS["list_company"].search(q) or _PATTERNS["list_company2"].search(q):
|
||||
elif _PATTERNS["list_contact"].search(q) or _PATTERNS["list_contact2"].search(q):
|
||||
search_term = _extract_search_term(query)
|
||||
desc = "List companies"
|
||||
desc = "List contacts"
|
||||
if search_term:
|
||||
desc += f" matching '{search_term}'"
|
||||
actions.append(
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/companies",
|
||||
"path": "/api/v1/contacts",
|
||||
"body": None,
|
||||
"description": desc,
|
||||
"confidence": 0.85,
|
||||
}
|
||||
)
|
||||
|
||||
# --- Contact intents ---
|
||||
elif _PATTERNS["create_contact"].search(q):
|
||||
name = _extract_name(query)
|
||||
actions.append(
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/contacts",
|
||||
"body": {"name": name or "New Contact"},
|
||||
"description": f"Create a new contact named '{name or 'New Contact'}'",
|
||||
"confidence": 0.9,
|
||||
}
|
||||
)
|
||||
|
||||
elif _PATTERNS["list_contact"].search(q):
|
||||
actions.append(
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/contacts",
|
||||
"body": None,
|
||||
"description": "List contacts",
|
||||
"confidence": 0.85,
|
||||
}
|
||||
)
|
||||
|
||||
# --- Workflow intents ---
|
||||
elif _PATTERNS["list_workflow"].search(q):
|
||||
actions.append(
|
||||
@@ -181,9 +153,9 @@ def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> l
|
||||
actions.append(
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/companies",
|
||||
"path": "/api/v1/contacts",
|
||||
"body": None,
|
||||
"description": "Show available companies (demonstration action)",
|
||||
"description": "Show available contacts (demonstration action)",
|
||||
"confidence": 0.3,
|
||||
}
|
||||
)
|
||||
@@ -222,9 +194,9 @@ def _extract_update_fields(query: str) -> dict[str, Any]:
|
||||
if re.search(r"\bphone\b", query, re.IGNORECASE):
|
||||
match = _PHONE_PAT.search(query)
|
||||
if match:
|
||||
fields["phone"] = match.group(1).strip()
|
||||
fields["phone_1"] = match.group(1).strip()
|
||||
if re.search(r"\bemail\b", query, re.IGNORECASE):
|
||||
match = _EMAIL_PAT.search(query)
|
||||
if match:
|
||||
fields["email"] = match.group(1).strip()
|
||||
fields["email_1"] = match.group(1).strip()
|
||||
return fields or {"name": "Updated Name"}
|
||||
|
||||
@@ -13,9 +13,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Core system permissions ──
|
||||
CORE_PERMISSIONS: list[dict[str, str]] = [
|
||||
{"key": "companies:read", "label": "Companies: Read", "category": "core", "module": "companies"},
|
||||
{"key": "companies:write", "label": "Companies: Write", "category": "core", "module": "companies"},
|
||||
{"key": "companies:delete", "label": "Companies: Delete", "category": "core", "module": "companies"},
|
||||
{"key": "contacts:read", "label": "Contacts: Read", "category": "core", "module": "contacts"},
|
||||
{"key": "contacts:write", "label": "Contacts: Write", "category": "core", "module": "contacts"},
|
||||
{"key": "contacts:delete", "label": "Contacts: Delete", "category": "core", "module": "contacts"},
|
||||
@@ -61,21 +58,12 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
|
||||
|
||||
# ── Core field definitions for field-level permissions ──
|
||||
CORE_FIELD_DEFINITIONS: list[dict[str, str]] = [
|
||||
{"module": "companies", "field": "name", "label": "Name", "sensitivity": "normal"},
|
||||
{"module": "companies", "field": "account_number", "label": "Account Number", "sensitivity": "normal"},
|
||||
{"module": "companies", "field": "industry", "label": "Industry", "sensitivity": "normal"},
|
||||
{"module": "companies", "field": "phone", "label": "Phone", "sensitivity": "normal"},
|
||||
{"module": "companies", "field": "email", "label": "Email", "sensitivity": "normal"},
|
||||
{"module": "companies", "field": "website", "label": "Website", "sensitivity": "normal"},
|
||||
{"module": "companies", "field": "description", "label": "Description", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "first_name", "label": "First Name", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "last_name", "label": "Last Name", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "email", "label": "Email", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "phone", "label": "Phone", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "mobile", "label": "Mobile", "sensitivity": "sensitive"},
|
||||
{"module": "contacts", "field": "position", "label": "Position", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "department", "label": "Department", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "linkedin_url", "label": "LinkedIn URL", "sensitivity": "sensitive"},
|
||||
{"module": "contacts", "field": "firstname", "label": "First Name", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "surname", "label": "Last Name", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "email_1", "label": "Email", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "phone_1", "label": "Phone", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "mobilephone", "label": "Mobile", "sensitivity": "sensitive"},
|
||||
{"module": "contacts", "field": "function", "label": "Position", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "notes", "label": "Notes", "sensitivity": "sensitive"},
|
||||
{"module": "users", "field": "email", "label": "Email", "sensitivity": "normal"},
|
||||
{"module": "users", "field": "name", "label": "Name", "sensitivity": "normal"},
|
||||
|
||||
@@ -5,7 +5,7 @@ Architecture:
|
||||
- Redis cache: resolved:{user_id}:{tenant_id} with 5-min TTL.
|
||||
- Permission-version stamping for immediate invalidation.
|
||||
- Resolution: allowed = (role ∪ groups), denied = (role.denied ∪ groups.denied), resolved = allowed − denied.
|
||||
- Wildcards: companies:*, *:read, *:* (bare * is forbidden).
|
||||
- Wildcards: contacts:*, *:read, *:* (bare * is forbidden).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,8 +35,8 @@ def _matches_permission(granted: str, required: str) -> bool:
|
||||
"""Check if a granted permission matches the required permission.
|
||||
|
||||
Supports wildcards:
|
||||
- companies:read → exact match
|
||||
- companies:* → all actions for companies
|
||||
- contacts:read → exact match
|
||||
- contacts:* → all actions for contacts
|
||||
- *:read → all modules, read action
|
||||
- *:* → everything (superadmin)
|
||||
"""
|
||||
@@ -67,9 +67,9 @@ def _normalize_permissions(permissions: Any) -> set[str]:
|
||||
"""Normalize permissions from JSONB to a set of strings.
|
||||
|
||||
Supports formats:
|
||||
- list[str]: ["companies:read", "contacts:write"]
|
||||
- dict[str, bool]: {"companies:read": true, "contacts:write": false}
|
||||
- dict[str, dict]: {"companies": {"read": true, "write": false}}
|
||||
- list[str]: ["contacts:read", "contacts:write"]
|
||||
- dict[str, bool]: {"contacts:read": true, "contacts:write": false}
|
||||
- dict[str, dict]: {"contacts": {"read": true, "write": false}}
|
||||
"""
|
||||
result: set[str] = set()
|
||||
if isinstance(permissions, list):
|
||||
@@ -156,7 +156,7 @@ async def resolve_permissions(
|
||||
if legacy_role == "admin":
|
||||
allowed.add("*:*")
|
||||
elif legacy_role == "editor":
|
||||
allowed |= {"companies:read", "companies:write", "contacts:read", "contacts:write",
|
||||
allowed |= {"contacts:read", "contacts:write", "contacts:read", "contacts:write",
|
||||
"users:read", "roles:read", "audit:read", "attachments:read",
|
||||
"attachments:write", "workflows:read", "workflows:write",
|
||||
"sequences:read", "sequences:write", "addresses:read", "addresses:write",
|
||||
@@ -164,7 +164,7 @@ async def resolve_permissions(
|
||||
"notifications:read", "notifications:write", "import_export:read",
|
||||
"import_export:write"}
|
||||
elif legacy_role == "viewer":
|
||||
allowed |= {"companies:read", "contacts:read", "users:read", "roles:read",
|
||||
allowed |= {"contacts:read", "contacts:read", "users:read", "roles:read",
|
||||
"audit:read", "attachments:read", "workflows:read", "sequences:read",
|
||||
"addresses:read", "taxes:read", "currencies:read",
|
||||
"notifications:read", "import_export:read"}
|
||||
@@ -265,7 +265,7 @@ def check_permission(resolved: dict[str, Any], required: str) -> bool:
|
||||
|
||||
Args:
|
||||
resolved: result from get_cached_permissions or resolve_permissions
|
||||
required: permission string like "companies:read"
|
||||
required: permission string like "contacts:read"
|
||||
"""
|
||||
if resolved.get("is_system_admin"):
|
||||
return True
|
||||
|
||||
@@ -43,7 +43,6 @@ from app.plugins.builtins.unified_search.jobs import (
|
||||
index_mails,
|
||||
index_file,
|
||||
index_contact,
|
||||
index_company,
|
||||
index_event,
|
||||
reindex,
|
||||
embedding_batch,
|
||||
@@ -57,7 +56,6 @@ class WorkerSettings:
|
||||
index_mails,
|
||||
index_file,
|
||||
index_contact,
|
||||
index_company,
|
||||
index_event,
|
||||
reindex,
|
||||
embedding_batch,
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ def require_permission(permission: str):
|
||||
"""FastAPI dependency factory: require a specific permission.
|
||||
|
||||
Usage:
|
||||
@router.get("/companies", dependencies=[Depends(require_permission("companies:read"))])
|
||||
@router.get("/contacts", dependencies=[Depends(require_permission("contacts:read"))])
|
||||
"""
|
||||
async def _check(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
|
||||
@@ -28,7 +28,6 @@ from app.routes import (
|
||||
ai_copilot,
|
||||
audit,
|
||||
auth,
|
||||
companies,
|
||||
contact_folders,
|
||||
contacts,
|
||||
entity_history,
|
||||
@@ -233,7 +232,6 @@ def create_app() -> FastAPI:
|
||||
app.include_router(groups.router)
|
||||
app.include_router(tenants.router)
|
||||
app.include_router(notifications.router)
|
||||
app.include_router(companies.router)
|
||||
app.include_router(contacts.router)
|
||||
app.include_router(contact_folders.router)
|
||||
app.include_router(entity_history.router)
|
||||
|
||||
@@ -5,7 +5,6 @@ from app.models.ai_conversation import AIConversation, AIMessage
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.audit import AuditLog, DeletionLog
|
||||
from app.models.auth import ApiToken, PasswordResetToken
|
||||
from app.models.company import Company
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.models.contact_folder import ContactFolder
|
||||
from app.models.entity_history import EntityHistory
|
||||
@@ -37,7 +36,6 @@ __all__ = [
|
||||
"NotificationPreference",
|
||||
"PasswordResetToken",
|
||||
"ApiToken",
|
||||
"Company",
|
||||
"Contact",
|
||||
"ContactPerson",
|
||||
"ContactFolder",
|
||||
|
||||
@@ -12,9 +12,9 @@ from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class Address(Base, TenantMixin):
|
||||
"""Polymorphic address entity — multiple addresses per company or contact.
|
||||
"""Polymorphic address entity — multiple addresses per contact.
|
||||
|
||||
entity_type: 'company' or 'contact'
|
||||
entity_type: 'contact'
|
||||
entity_id: FK to companies.id or contacts.id
|
||||
address_type: billing, shipping, headquarters, branch, private, other
|
||||
is_default: one default address per (entity_type, entity_id, address_type)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Backward-compat shim — Company is now Contact with type='company'.
|
||||
|
||||
This module re-exports Contact as Company for code that still imports
|
||||
from app.models.company. The old companies table no longer exists;
|
||||
all company data lives in the contacts table with type='company'.
|
||||
"""
|
||||
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
|
||||
# Backward-compat: Company is now just a Contact with type='company'
|
||||
Company = Contact
|
||||
|
||||
__all__ = ["Company", "ContactPerson"]
|
||||
@@ -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"),
|
||||
|
||||
@@ -128,8 +128,8 @@ MANIFEST_SCHEMA_DOC = ManifestSchemaResponse(
|
||||
description="An example plugin demonstrating the manifest schema.",
|
||||
dependencies=[],
|
||||
routes=[],
|
||||
events=["company.created", "contact.created"],
|
||||
events=["contact.created"],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=["companies.read"],
|
||||
permissions=["contacts.read"],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ from app.routes import (
|
||||
ai_copilot, # noqa: F401
|
||||
audit, # noqa: F401
|
||||
auth, # noqa: F401
|
||||
companies, # noqa: F401
|
||||
contacts, # noqa: F401
|
||||
entity_history, # noqa: F401
|
||||
currencies, # noqa: F401
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
"""Company routes — full CRUD, FTS search, filter, pagination, export, N:M, emails."""
|
||||
|
||||
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 require_permission
|
||||
from app.schemas.company import CompanyCreate, CompanyUpdate
|
||||
from app.services import company_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/companies", tags=["companies"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_companies(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(None),
|
||||
industry: str | None = Query(None),
|
||||
sort_by: str = Query("name"),
|
||||
sort_order: str = Query("asc", pattern="^(asc|desc)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("companies:read")),
|
||||
):
|
||||
"""List companies with pagination, FTS search, industry filter, and sorting."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
result = await company_service.list_companies(
|
||||
db,
|
||||
tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
industry=industry,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
resolved_perms=current_user,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_company(
|
||||
body: CompanyCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("companies:write")),
|
||||
):
|
||||
"""Create a company. Requires write permission."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
|
||||
data = body.model_dump()
|
||||
return await company_service.create_company(db, tenant_id, user_id, data)
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_companies(
|
||||
format: str = Query("csv", pattern="^(csv|xlsx)$"),
|
||||
industry: str | None = Query(None),
|
||||
search: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("companies:read")),
|
||||
):
|
||||
"""Export companies as CSV (streaming) or XLSX (buffered)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
|
||||
if format == "csv":
|
||||
import csv
|
||||
import io
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models.company import Company
|
||||
|
||||
async def generate_csv():
|
||||
"""Async generator yielding CSV rows (streaming, not buffered).
|
||||
Creates its own DB session to avoid request-scoped session issues.
|
||||
"""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
header = [
|
||||
"id", "name", "account_number", "industry", "phone",
|
||||
"email", "website", "description", "created_at", "updated_at",
|
||||
]
|
||||
writer.writerow(header)
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
batch_size = 500
|
||||
offset = 0
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
while True:
|
||||
q = sa_select(Company).where(
|
||||
Company.tenant_id == tenant_id,
|
||||
Company.deleted_at.is_(None),
|
||||
)
|
||||
if industry:
|
||||
q = q.where(Company.industry == industry)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
q = q.where(Company.name.ilike(pattern))
|
||||
q = q.order_by(Company.name.asc()).offset(offset).limit(batch_size)
|
||||
result = await session.execute(q)
|
||||
companies = result.scalars().all()
|
||||
if not companies:
|
||||
break
|
||||
for c in companies:
|
||||
writer.writerow([
|
||||
str(c.id), c.name, c.account_number or "", c.industry or "",
|
||||
c.phone or "", c.email or "", c.website or "",
|
||||
c.description or "",
|
||||
c.created_at.isoformat() if c.created_at else "",
|
||||
c.updated_at.isoformat() if c.updated_at else "",
|
||||
])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
offset += batch_size
|
||||
|
||||
return StreamingResponse(
|
||||
generate_csv(),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=companies.csv"},
|
||||
)
|
||||
elif format == "xlsx":
|
||||
xlsx_data = await company_service.export_companies_xlsx(
|
||||
db,
|
||||
tenant_id,
|
||||
industry=industry,
|
||||
search=search,
|
||||
)
|
||||
return Response(
|
||||
content=xlsx_data,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=companies.xlsx"},
|
||||
)
|
||||
raise HTTPException(400, detail={"detail": "Invalid format", "code": "invalid_format"})
|
||||
|
||||
|
||||
@router.get("/{company_id}")
|
||||
async def get_company(
|
||||
company_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("companies:read")),
|
||||
):
|
||||
"""Get a single company with contacts array. Cross-tenant returns 404."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
cid = uuid.UUID(company_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
|
||||
) from None
|
||||
|
||||
data = await company_service.get_company_detail(db, tenant_id, cid, resolved_perms=current_user)
|
||||
if data is None:
|
||||
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
||||
return data
|
||||
|
||||
|
||||
@router.put("/{company_id}")
|
||||
async def update_company(
|
||||
company_id: str,
|
||||
body: CompanyUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("companies:write")),
|
||||
):
|
||||
"""Update a company (full PUT)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
|
||||
try:
|
||||
cid = uuid.UUID(company_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
|
||||
) from None
|
||||
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
result = await company_service.update_company(db, tenant_id, user_id, cid, data)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{company_id}")
|
||||
async def delete_company(
|
||||
company_id: str,
|
||||
cascade: bool = Query(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("companies:write")),
|
||||
):
|
||||
"""Soft-delete a company. Use cascade=true to also delete N:M links."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
|
||||
try:
|
||||
cid = uuid.UUID(company_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
|
||||
) from None
|
||||
|
||||
deleted = await company_service.soft_delete_company(
|
||||
db, tenant_id, user_id, cid, cascade=cascade
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/{company_id}/contacts/{contact_id}")
|
||||
async def link_company_contact(
|
||||
company_id: str,
|
||||
contact_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("companies:write")),
|
||||
):
|
||||
"""Link a contact to a company (N:M)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
|
||||
try:
|
||||
comp_id = uuid.UUID(company_id)
|
||||
cont_id = uuid.UUID(contact_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
|
||||
|
||||
result = await company_service.link_contact(db, tenant_id, user_id, comp_id, cont_id)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
404, detail={"detail": "Company or contact not found", "code": "not_found"}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{company_id}/contacts/{contact_id}")
|
||||
async def unlink_company_contact(
|
||||
company_id: str,
|
||||
contact_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("companies:write")),
|
||||
):
|
||||
"""Unlink a contact from a company (N:M)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
|
||||
try:
|
||||
comp_id = uuid.UUID(company_id)
|
||||
cont_id = uuid.UUID(contact_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
|
||||
|
||||
unlinked = await company_service.unlink_contact(db, tenant_id, user_id, comp_id, cont_id)
|
||||
if not unlinked:
|
||||
raise HTTPException(404, detail={"detail": "Link not found", "code": "not_found"})
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/{company_id}/emails")
|
||||
async def get_company_emails(
|
||||
company_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("companies:read")),
|
||||
):
|
||||
"""Get emails for a company (mail plugin inactive — returns empty array)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
cid = uuid.UUID(company_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
|
||||
) from None
|
||||
|
||||
# Verify company exists
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.company import Company
|
||||
|
||||
q = select(Company).where(
|
||||
Company.id == cid,
|
||||
Company.tenant_id == tenant_id,
|
||||
Company.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
||||
|
||||
return []
|
||||
@@ -24,9 +24,6 @@ router = APIRouter(prefix="/api/v1/roles", tags=["roles"])
|
||||
|
||||
|
||||
SYSTEM_PERMISSIONS: list[dict[str, str]] = [
|
||||
{"key": "companies:read", "label": "Companies: Read", "category": "system"},
|
||||
{"key": "companies:write", "label": "Companies: Write", "category": "system"},
|
||||
{"key": "companies:delete", "label": "Companies: Delete", "category": "system"},
|
||||
{"key": "contacts:read", "label": "Contacts: Read", "category": "system"},
|
||||
{"key": "contacts:write", "label": "Contacts: Write", "category": "system"},
|
||||
{"key": "contacts:delete", "label": "Contacts: Delete", "category": "system"},
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AddressCreate(BaseModel):
|
||||
entity_type: str = Field(..., pattern="^(company|contact)$", description="'company' or 'contact'")
|
||||
entity_type: str = Field(..., pattern="^contact$", description="'contact'")
|
||||
entity_id: str = Field(..., description="UUID of the parent entity")
|
||||
label: str = Field(..., min_length=1, max_length=100)
|
||||
address_type: str = Field(
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""Company schemas — create, update, read, list, pagination, search, filter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CompanyCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
account_number: str | None = Field(None, max_length=40)
|
||||
industry: str | None = Field(None, max_length=50)
|
||||
phone: str | None = Field(None, max_length=30)
|
||||
email: str | None = Field(None, max_length=255)
|
||||
website: str | None = Field(None, max_length=500)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CompanyUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
account_number: str | None = Field(None, max_length=40)
|
||||
industry: str | None = Field(None, max_length=50)
|
||||
phone: str | None = Field(None, max_length=30)
|
||||
email: str | None = Field(None, max_length=255)
|
||||
website: str | None = Field(None, max_length=500)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CompanyResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
account_number: str | None = None
|
||||
industry: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
website: str | None = None
|
||||
description: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class CompanyDetailResponse(CompanyResponse):
|
||||
contacts: list[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CompanyListResponse(BaseModel):
|
||||
items: list[CompanyResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class CompanyExportRequest(BaseModel):
|
||||
format: str = Field("csv", pattern="^(csv|xlsx)$")
|
||||
industry: str | None = None
|
||||
search: str | None = None
|
||||
@@ -13,7 +13,7 @@ from app.core.audit import log_audit
|
||||
from app.models.address import Address
|
||||
|
||||
|
||||
VALID_ENTITY_TYPES = {"company", "contact"}
|
||||
VALID_ENTITY_TYPES = {"contact"}
|
||||
VALID_ADDRESS_TYPES = {"billing", "shipping", "headquarters", "branch", "private", "other"}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ async def migrate_existing_addresses(db: AsyncSession) -> int:
|
||||
existing = await db.execute(
|
||||
select(Address).where(
|
||||
Address.tenant_id == company.tenant_id,
|
||||
Address.entity_type == "company",
|
||||
Address.entity_type == "contact",
|
||||
Address.entity_id == company.id,
|
||||
Address.address_type == "headquarters",
|
||||
Address.deleted_at.is_(None),
|
||||
@@ -217,7 +217,7 @@ async def migrate_existing_addresses(db: AsyncSession) -> int:
|
||||
|
||||
addr = Address(
|
||||
tenant_id=company.tenant_id,
|
||||
entity_type="company",
|
||||
entity_type="contact",
|
||||
entity_id=company.id,
|
||||
label="Hauptsitz",
|
||||
address_type="headquarters",
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.ai.llm_client import get_llm_client
|
||||
from app.core.audit import log_audit
|
||||
from app.core.auth import check_permission
|
||||
from app.models.ai_conversation import AIConversation, AIMessage
|
||||
from app.models.contact import Contact as Company
|
||||
from app.models.contact import Contact
|
||||
from app.models.contact import Contact
|
||||
from app.models.workflow import Workflow
|
||||
|
||||
@@ -306,16 +306,14 @@ async def _execute_api_action(
|
||||
) -> dict[str, Any]:
|
||||
"""Execute an API action directly against the database.
|
||||
|
||||
Supports companies and contacts CRUD, plus workflow listing.
|
||||
Supports contacts CRUD (including company-type contacts), plus workflow listing.
|
||||
"""
|
||||
# Parse path to determine entity and operation
|
||||
parts = path.replace("/api/v1/", "").strip("/").split("/")
|
||||
entity = parts[0] if parts else ""
|
||||
entity_id = parts[1] if len(parts) > 1 else None
|
||||
|
||||
if entity == "companies":
|
||||
return await _exec_companies(db, tenant_id, user_id, method, entity_id, body)
|
||||
elif entity == "contacts":
|
||||
if entity in ("companies", "contacts"):
|
||||
return await _exec_contacts(db, tenant_id, user_id, method, entity_id, body)
|
||||
elif entity == "workflows":
|
||||
return await _exec_workflows(db, tenant_id, user_id, method, entity_id, body)
|
||||
@@ -323,7 +321,7 @@ async def _execute_api_action(
|
||||
return {"error": f"Unsupported entity: {entity}", "status_code": 400, "success": False}
|
||||
|
||||
|
||||
async def _exec_companies(
|
||||
async def _exec_contacts\(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
@@ -331,99 +329,7 @@ async def _exec_companies(
|
||||
entity_id: str | None,
|
||||
body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Execute company operations."""
|
||||
if method == "GET":
|
||||
result = await db.execute(
|
||||
select(Company).where(
|
||||
Company.tenant_id == tenant_id,
|
||||
Company.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
companies = result.scalars().all()
|
||||
return {
|
||||
"success": True,
|
||||
"status_code": 200,
|
||||
"data": [{"id": str(c.id), "name": c.name, "industry": c.industry} for c in companies],
|
||||
}
|
||||
|
||||
elif method == "POST":
|
||||
company = Company(
|
||||
tenant_id=tenant_id,
|
||||
name=body.get("name", "Untitled"),
|
||||
industry=body.get("industry"),
|
||||
phone=body.get("phone"),
|
||||
email=body.get("email"),
|
||||
website=body.get("website"),
|
||||
description=body.get("description"),
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(company)
|
||||
await db.flush()
|
||||
return {
|
||||
"success": True,
|
||||
"status_code": 201,
|
||||
"data": {"id": str(company.id), "name": company.name},
|
||||
}
|
||||
|
||||
elif method == "DELETE":
|
||||
if not entity_id or entity_id == "{id}":
|
||||
return {"error": "Company ID required", "status_code": 400, "success": False}
|
||||
comp_uuid = uuid.UUID(entity_id)
|
||||
result = await db.execute(
|
||||
select(Company).where(
|
||||
Company.id == comp_uuid,
|
||||
Company.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
company = result.scalar_one_or_none()
|
||||
if company is None:
|
||||
return {"error": "Company not found", "status_code": 404, "success": False}
|
||||
company.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return {
|
||||
"success": True,
|
||||
"status_code": 200,
|
||||
"data": {"id": str(company.id), "deleted": True},
|
||||
}
|
||||
|
||||
elif method == "PATCH":
|
||||
if not entity_id or entity_id == "{id}":
|
||||
return {"error": "Company ID required", "status_code": 400, "success": False}
|
||||
comp_uuid = uuid.UUID(entity_id)
|
||||
result = await db.execute(
|
||||
select(Company).where(
|
||||
Company.id == comp_uuid,
|
||||
Company.tenant_id == tenant_id,
|
||||
Company.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
company = result.scalar_one_or_none()
|
||||
if company is None:
|
||||
return {"error": "Company not found", "status_code": 404, "success": False}
|
||||
for key in ("name", "industry", "phone", "email", "website", "description"):
|
||||
if key in body:
|
||||
setattr(company, key, body[key])
|
||||
company.updated_by = user_id
|
||||
await db.flush()
|
||||
return {
|
||||
"success": True,
|
||||
"status_code": 200,
|
||||
"data": {"id": str(company.id), "name": company.name, "industry": company.industry},
|
||||
}
|
||||
|
||||
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
||||
|
||||
|
||||
async def _exec_contacts(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
method: str,
|
||||
entity_id: str | None,
|
||||
body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Execute contact operations."""
|
||||
"""Execute contact operations (unified: company + person)."""
|
||||
if method == "GET":
|
||||
result = await db.execute(
|
||||
select(Contact).where(
|
||||
@@ -435,15 +341,16 @@ async def _exec_contacts(
|
||||
return {
|
||||
"success": True,
|
||||
"status_code": 200,
|
||||
"data": [{"id": str(c.id), "name": c.name, "email": c.email} for c in contacts],
|
||||
"data": [{"id": str(c.id), "name": c.name or c.displayname, "email": c.email_1, "type": c.type} for c in contacts],
|
||||
}
|
||||
|
||||
elif method == "POST":
|
||||
contact = Contact(
|
||||
tenant_id=tenant_id,
|
||||
type=body.get("type", "company"),
|
||||
name=body.get("name", "Untitled"),
|
||||
email=body.get("email"),
|
||||
phone=body.get("phone"),
|
||||
email_1=body.get("email"),
|
||||
phone_1=body.get("phone"),
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
@@ -452,7 +359,7 @@ async def _exec_contacts(
|
||||
return {
|
||||
"success": True,
|
||||
"status_code": 201,
|
||||
"data": {"id": str(contact.id), "name": contact.name},
|
||||
"data": {"id": str(contact.id), "name": contact.name, "type": contact.type},
|
||||
}
|
||||
|
||||
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
"""Company service — now operates on Contact with type='company'.
|
||||
|
||||
Backward-compat layer: all company operations are now contact operations
|
||||
filtered by type='company'. CompanyContact is replaced by ContactPerson 1:N.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, func, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
|
||||
|
||||
def _company_to_dict(c: Contact) -> dict:
|
||||
"""Serialize a company (Contact with type=company) to dict."""
|
||||
return {
|
||||
"id": str(c.id),
|
||||
"name": c.name or "",
|
||||
"account_number": c.code or "",
|
||||
"industry": (c.custom or {}).get("industry") if c.custom else None,
|
||||
"phone": c.phone_1,
|
||||
"email": c.email_1,
|
||||
"website": c.website,
|
||||
"description": (c.custom or {}).get("description") if c.custom else None,
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def list_companies(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
search: str | None = None,
|
||||
industry: str | None = None,
|
||||
sort_by: str = "name",
|
||||
sort_order: str = "asc",
|
||||
resolved_perms: dict | None = None,
|
||||
) -> dict:
|
||||
"""List companies (contacts with type='company')."""
|
||||
base = select(Contact).where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.type == "company",
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
|
||||
if search:
|
||||
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
|
||||
|
||||
count_q = select(func.count()).select_from(base.subquery())
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
|
||||
sort_col = getattr(Contact, sort_by if sort_by != "name" else "name", Contact.name)
|
||||
if sort_order == "desc":
|
||||
sort_col = sort_col.desc()
|
||||
base = base.order_by(sort_col)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
base = base.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(base)
|
||||
companies = result.scalars().all()
|
||||
|
||||
return {
|
||||
"items": [_company_to_dict(c) for c in companies],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
async def create_company(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
|
||||
) -> dict:
|
||||
"""Create a company (contact with type='company')."""
|
||||
# Map old fields to new
|
||||
contact_data = {
|
||||
"type": "company",
|
||||
"name": data.get("name", ""),
|
||||
"code": data.get("account_number"),
|
||||
"phone_1": data.get("phone"),
|
||||
"email_1": data.get("email"),
|
||||
"website": data.get("website"),
|
||||
"displayname": data.get("name", ""),
|
||||
}
|
||||
# Store industry/description in custom
|
||||
custom = {}
|
||||
if data.get("industry"):
|
||||
custom["industry"] = data["industry"]
|
||||
if data.get("description"):
|
||||
custom["description"] = data["description"]
|
||||
if custom:
|
||||
contact_data["custom"] = custom
|
||||
|
||||
contact = Contact(tenant_id=tenant_id, created_by=user_id, updated_by=user_id, **contact_data)
|
||||
db.add(contact)
|
||||
await db.flush()
|
||||
return _company_to_dict(contact)
|
||||
|
||||
|
||||
async def get_company_detail(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, company_id: str, resolved_perms: dict | None = None
|
||||
) -> dict:
|
||||
"""Get company detail with contact persons."""
|
||||
q = (
|
||||
select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
.where(
|
||||
Contact.id == uuid.UUID(company_id),
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.type == "company",
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
contact = result.scalar_one_or_none()
|
||||
if not contact:
|
||||
raise ValueError("Company not found")
|
||||
|
||||
data = _company_to_dict(contact)
|
||||
data["contacts"] = [
|
||||
{
|
||||
"id": str(cp.id),
|
||||
"first_name": cp.firstname or "",
|
||||
"last_name": cp.lastname or "",
|
||||
"email": cp.email,
|
||||
"phone": cp.phone,
|
||||
"position": cp.function,
|
||||
}
|
||||
for cp in (contact.contact_persons or [])
|
||||
if cp.deleted_at is None
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
async def update_company(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, data: dict
|
||||
) -> dict:
|
||||
"""Update a company."""
|
||||
q = select(Contact).where(
|
||||
Contact.id == uuid.UUID(company_id),
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.type == "company",
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
contact = result.scalar_one_or_none()
|
||||
if not contact:
|
||||
raise ValueError("Company not found")
|
||||
|
||||
if "name" in data:
|
||||
contact.name = data["name"]
|
||||
contact.displayname = data["name"]
|
||||
if "account_number" in data:
|
||||
contact.code = data["account_number"]
|
||||
if "phone" in data:
|
||||
contact.phone_1 = data["phone"]
|
||||
if "email" in data:
|
||||
contact.email_1 = data["email"]
|
||||
if "website" in data:
|
||||
contact.website = data["website"]
|
||||
|
||||
# Store industry/description in custom
|
||||
custom = dict(contact.custom or {})
|
||||
if "industry" in data:
|
||||
custom["industry"] = data["industry"]
|
||||
if "description" in data:
|
||||
custom["description"] = data["description"]
|
||||
if custom:
|
||||
contact.custom = custom
|
||||
|
||||
contact.updated_by = user_id
|
||||
await db.flush()
|
||||
return _company_to_dict(contact)
|
||||
|
||||
|
||||
async def soft_delete_company(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, company_id: str, cascade: bool = True
|
||||
) -> bool:
|
||||
"""Soft-delete a company."""
|
||||
from datetime import datetime, timezone
|
||||
q = select(Contact).where(
|
||||
Contact.id == uuid.UUID(company_id),
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.type == "company",
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
contact = result.scalar_one_or_none()
|
||||
if not contact:
|
||||
return False
|
||||
contact.deleted_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def export_companies_xlsx(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, industry: str | None = None, search: str | None = None
|
||||
) -> bytes:
|
||||
"""Export companies as XLSX."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
base = select(Contact).where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.type == "company",
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
if search:
|
||||
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
|
||||
base = base.order_by(Contact.name)
|
||||
|
||||
result = await db.execute(base)
|
||||
companies = result.scalars().all()
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Companies"
|
||||
ws.append(["Name", "Account Number", "Industry", "Phone", "Email", "Website"])
|
||||
for c in companies:
|
||||
custom = c.custom or {}
|
||||
ws.append([
|
||||
c.name or "", c.code or "", custom.get("industry", ""),
|
||||
c.phone_1 or "", c.email_1 or "", c.website or "",
|
||||
])
|
||||
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
async def link_contact(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, contact_id: str
|
||||
) -> dict:
|
||||
"""Link a contact person to a company (create ContactPerson)."""
|
||||
# In new model, contact_id is used to create a ContactPerson under the company contact
|
||||
# This is a compat shim — the old API expected a separate Contact entity
|
||||
# Now we just create a ContactPerson linked to the company
|
||||
cp = ContactPerson(
|
||||
tenant_id=tenant_id,
|
||||
contact_id=uuid.UUID(company_id),
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(cp)
|
||||
await db.flush()
|
||||
return {"id": str(cp.id), "company_id": company_id, "contact_id": contact_id}
|
||||
|
||||
|
||||
async def unlink_contact(
|
||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, contact_id: str
|
||||
) -> bool:
|
||||
"""Unlink a contact person from a company (soft-delete ContactPerson)."""
|
||||
from datetime import datetime, timezone
|
||||
q = select(ContactPerson).where(
|
||||
ContactPerson.contact_id == uuid.UUID(company_id),
|
||||
ContactPerson.id == uuid.UUID(contact_id),
|
||||
ContactPerson.tenant_id == tenant_id,
|
||||
ContactPerson.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
cp = result.scalar_one_or_none()
|
||||
if not cp:
|
||||
return False
|
||||
cp.deleted_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return True
|
||||
@@ -302,5 +302,5 @@ def register_workflow_event_handlers() -> None:
|
||||
await handle_event(db, tenant_id, event_name, payload)
|
||||
|
||||
# Subscribe to common events
|
||||
for event_name in ("user.created", "company.created", "contact.created"):
|
||||
for event_name in ("user.created", "contact.created"):
|
||||
event_bus.subscribe(event_name, _workflow_event_handler)
|
||||
|
||||
Reference in New Issue
Block a user