refactor: simplify CRM MCP architecture
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .crm_mixin import CrmMixin
|
||||
from .crm_shared import (
|
||||
MAX_PAGE_SIZE,
|
||||
MAX_SCAN_RECORDS,
|
||||
Any,
|
||||
Mapping,
|
||||
_data,
|
||||
_normalize_pagination,
|
||||
_page_from_offset,
|
||||
_paginated_rows,
|
||||
_pagination_metadata,
|
||||
_relation_refs,
|
||||
_total,
|
||||
build_and_filter,
|
||||
build_date_range_condition,
|
||||
build_eq_condition,
|
||||
build_relation_id_condition,
|
||||
build_relation_id_in_condition,
|
||||
build_search_filter,
|
||||
)
|
||||
|
||||
|
||||
class CrmRecordsMixin(CrmMixin):
|
||||
def list_customers(
|
||||
self,
|
||||
query: str | None = None,
|
||||
status: str | None = None,
|
||||
owner: str | None = None,
|
||||
team: str | None = None,
|
||||
source: str | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
include_raw: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
limit, offset = _normalize_pagination(limit, offset)
|
||||
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
|
||||
if owner_error:
|
||||
return {"error": owner_error}
|
||||
team_member_ids, team_error = self._resolve_team_member_ids_or_error(team)
|
||||
if team_error:
|
||||
return {"error": team_error}
|
||||
filter_value = build_and_filter(
|
||||
[
|
||||
build_eq_condition(self.config.customer.status_field, status),
|
||||
build_relation_id_condition(self.config.customer.owner_field, owner_id),
|
||||
build_relation_id_in_condition(self.config.customer.owner_field, team_member_ids),
|
||||
build_eq_condition(self.config.customer.source_field, source),
|
||||
build_search_filter(query, self.config.customer.search_fields),
|
||||
]
|
||||
)
|
||||
result = self._list_with_projection_fallback(
|
||||
self.config.customer.resource,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
filter=filter_value,
|
||||
fields=(self.config.customer.fields or []) if include_raw else self._customer_compact_fields(),
|
||||
sort=self.config.customer.sort,
|
||||
)
|
||||
result["results"] = self._compact_customers(result["results"], include_raw=include_raw)
|
||||
result["timezone"] = self.config.timezone
|
||||
return result
|
||||
|
||||
def get_customer(
|
||||
self,
|
||||
customer_id: Any,
|
||||
include_contacts: bool = True,
|
||||
include_recent_followups: bool = True,
|
||||
followup_limit: int = 10,
|
||||
include_raw: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
fields = self.config.customer.fields if include_raw else self._customer_compact_fields(detail=True)
|
||||
row = self._single(
|
||||
self.client.get_record(
|
||||
self.config.customer.resource,
|
||||
customer_id,
|
||||
fields=fields,
|
||||
)
|
||||
)
|
||||
if not isinstance(row, Mapping):
|
||||
return {
|
||||
"error": {
|
||||
"code": "customer_not_found",
|
||||
"message": f"No customer matches id {customer_id!r}.",
|
||||
"hint": "Use list_customers to find the customer id.",
|
||||
"retryable": False,
|
||||
}
|
||||
}
|
||||
result: dict[str, Any] = {
|
||||
"customer": self._compact_customer(row, detail=True, include_raw=include_raw),
|
||||
}
|
||||
if include_contacts:
|
||||
# Phone and email are deliberately withheld here: a customer overview
|
||||
# does not need personal contact details. list_contacts still serves them.
|
||||
contacts_result = self.list_contacts(
|
||||
customer_id=customer_id,
|
||||
limit=50,
|
||||
offset=0,
|
||||
include_details=False,
|
||||
)
|
||||
result["contacts"] = contacts_result["results"]
|
||||
result["contacts_pagination"] = _pagination_metadata(contacts_result)
|
||||
if include_recent_followups:
|
||||
followups_result = self.list_followups(
|
||||
customer_id=customer_id,
|
||||
limit=followup_limit,
|
||||
offset=0,
|
||||
include_raw=include_raw,
|
||||
)
|
||||
result["recent_followups"] = followups_result["results"]
|
||||
result["recent_followups_pagination"] = _pagination_metadata(followups_result)
|
||||
result["timezone"] = self.config.timezone
|
||||
return result
|
||||
|
||||
def list_projects(
|
||||
self,
|
||||
*,
|
||||
customer_id: Any | None = None,
|
||||
team: str | None = None,
|
||||
stage: str | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
include_raw: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
limit, offset = _normalize_pagination(limit, offset)
|
||||
team_member_ids, team_error = self._resolve_team_member_ids_or_error(team)
|
||||
if team_error:
|
||||
return {"error": team_error}
|
||||
stage_value = stage
|
||||
if stage:
|
||||
if stage in self.config.project.progress_values:
|
||||
stage_value = self.config.project.progress_values[stage]
|
||||
elif stage not in self.config.project.progress_values.values():
|
||||
return {
|
||||
"error": {
|
||||
"code": "invalid_project_stage",
|
||||
"message": "stage must be one of the configured project progress labels or values",
|
||||
}
|
||||
}
|
||||
filter_value = build_and_filter(
|
||||
[
|
||||
build_eq_condition(self.config.project.customer_field, customer_id),
|
||||
build_relation_id_in_condition(
|
||||
[self.config.project.customer_relation_field, self.config.customer.owner_field, "id"],
|
||||
team_member_ids,
|
||||
),
|
||||
build_eq_condition(self.config.project.progress_field, stage_value),
|
||||
]
|
||||
)
|
||||
result = self._list_with_projection_fallback(
|
||||
self.config.project.resource,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
filter=filter_value,
|
||||
fields=self._project_fields(),
|
||||
sort=["-updatedAt"],
|
||||
)
|
||||
result["results"] = [self._compact_project(row, include_raw=include_raw) for row in result["results"]]
|
||||
result["timezone"] = self.config.timezone
|
||||
return result
|
||||
|
||||
def get_project(
|
||||
self,
|
||||
project_id: Any,
|
||||
*,
|
||||
include_contacts: bool = True,
|
||||
include_followups: bool = True,
|
||||
include_updates: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
row = self._single(
|
||||
self.client.get_record(
|
||||
self.config.project.resource,
|
||||
project_id,
|
||||
fields=self._project_fields(),
|
||||
)
|
||||
)
|
||||
if not isinstance(row, Mapping):
|
||||
return {"error": {"code": "project_not_found", "message": f"No project matched id {project_id!r}"}}
|
||||
project = self._compact_project(row, include_raw=False)
|
||||
result: dict[str, Any] = {"project": project, "timezone": self.config.timezone}
|
||||
if include_contacts:
|
||||
# Was returning raw contact rows, phone and email included.
|
||||
result["contacts"] = _relation_refs(row, [self.config.project.contact_field], self._contact_spec())
|
||||
if include_followups:
|
||||
followups_result = self.list_followups(project_id=project_id, limit=100, offset=0)
|
||||
result["followups"] = followups_result["results"]
|
||||
result["followups_pagination"] = _pagination_metadata(followups_result)
|
||||
if include_updates:
|
||||
updates, truncation = self._scan_records(
|
||||
self.config.project.update_resource,
|
||||
filter=build_and_filter(
|
||||
[build_relation_id_condition(self.config.project.update_project_field, project_id)]
|
||||
),
|
||||
fields=[
|
||||
"id",
|
||||
self.config.project.update_project_field,
|
||||
self.config.project.update_date_field,
|
||||
self.config.project.update_content_field,
|
||||
self.config.project.update_attachment_field,
|
||||
"createdBy",
|
||||
"createdAt",
|
||||
],
|
||||
sort=[self.config.project.update_date_field, "createdAt"],
|
||||
)
|
||||
result["updates"] = [self._compact_project_update(update) for update in updates]
|
||||
if truncation:
|
||||
result["data_quality_warnings"] = ["project updates were truncated at the scan limit"]
|
||||
result["truncation"] = {"updates": truncation}
|
||||
return result
|
||||
|
||||
def list_contacts(
|
||||
self,
|
||||
customer_id: Any | None = None,
|
||||
query: str | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
include_details: bool = True,
|
||||
include_raw: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
limit, offset = _normalize_pagination(limit, offset)
|
||||
filter_value = build_and_filter(
|
||||
[
|
||||
build_relation_id_condition(self.config.contact.customer_field, customer_id),
|
||||
build_search_filter(query, self.config.contact.search_fields),
|
||||
]
|
||||
)
|
||||
result = self._list_with_projection_fallback(
|
||||
self.config.contact.resource,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
filter=filter_value,
|
||||
fields=(
|
||||
(self.config.contact.fields or [])
|
||||
if include_raw
|
||||
else self._contact_compact_fields(include_details=include_details)
|
||||
),
|
||||
sort=self.config.contact.sort,
|
||||
)
|
||||
result["results"] = self._compact_contacts(
|
||||
result["results"], include_details=include_details, include_raw=include_raw
|
||||
)
|
||||
result["timezone"] = self.config.timezone
|
||||
return result
|
||||
|
||||
def list_followups(
|
||||
self,
|
||||
customer_id: Any | None = None,
|
||||
contact_id: Any | None = None,
|
||||
owner: str | None = None,
|
||||
team: str | None = None,
|
||||
project_id: Any | None = None,
|
||||
followup_type: str | None = None,
|
||||
record_status: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
include_raw: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
limit, offset = _normalize_pagination(limit, offset)
|
||||
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
|
||||
if owner_error:
|
||||
return {"error": owner_error}
|
||||
team_member_ids, team_error = self._resolve_team_member_ids_or_error(team)
|
||||
if team_error:
|
||||
return {"error": team_error}
|
||||
filter_value = build_and_filter(
|
||||
[
|
||||
build_relation_id_condition(self.config.followup.customer_field, customer_id),
|
||||
build_relation_id_condition(self.config.followup.contact_field, contact_id),
|
||||
build_relation_id_condition(self.config.followup.owner_field, owner_id),
|
||||
build_relation_id_in_condition(self.config.followup.owner_field, team_member_ids),
|
||||
build_relation_id_condition(self.config.followup.project_field, project_id),
|
||||
build_eq_condition(self.config.followup.type_field, followup_type),
|
||||
build_eq_condition(self.config.followup.status_field, record_status),
|
||||
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
|
||||
]
|
||||
)
|
||||
result = self._list_with_projection_fallback(
|
||||
self.config.followup.resource,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
filter=filter_value,
|
||||
fields=(self.config.followup.fields or []) if include_raw else self._followup_compact_fields(),
|
||||
sort=self.config.followup.sort,
|
||||
)
|
||||
result["results"] = self._compact_followups(result["results"], include_raw=include_raw)
|
||||
result["timezone"] = self.config.timezone
|
||||
return result
|
||||
|
||||
def _list_records_window(
|
||||
self,
|
||||
resource: str,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
page = _page_from_offset(limit, offset)
|
||||
intra_page_offset = offset % limit
|
||||
response = self.client.list_records(
|
||||
resource,
|
||||
**kwargs,
|
||||
page=page,
|
||||
page_size=limit,
|
||||
)
|
||||
rows = self._data(response)
|
||||
selected = rows[intra_page_offset:]
|
||||
if intra_page_offset and len(rows) == limit and len(selected) < limit:
|
||||
next_response = self.client.list_records(
|
||||
resource,
|
||||
**kwargs,
|
||||
page=page + 1,
|
||||
page_size=limit,
|
||||
)
|
||||
selected.extend(self._data(next_response)[: limit - len(selected)])
|
||||
selected = selected[:limit]
|
||||
total = _total(response, offset + len(selected))
|
||||
return _paginated_rows(selected, total, limit, offset)
|
||||
|
||||
def _scan_records(
|
||||
self,
|
||||
resource: str,
|
||||
*,
|
||||
page_size: int = MAX_PAGE_SIZE,
|
||||
max_records: int = MAX_SCAN_RECORDS,
|
||||
**kwargs: Any,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, int] | None]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
total: int | None = None
|
||||
while len(rows) < max_records:
|
||||
response = self.client.list_records(
|
||||
resource,
|
||||
**kwargs,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
page_rows = self._data(response)
|
||||
if total is None:
|
||||
total = _total(response, len(page_rows))
|
||||
rows.extend(page_rows)
|
||||
if not page_rows or len(page_rows) < page_size or len(rows) >= total:
|
||||
break
|
||||
page += 1
|
||||
selected = rows[:max_records]
|
||||
if total is None or len(selected) >= total:
|
||||
return selected, None
|
||||
return selected, {
|
||||
"total": total,
|
||||
"returned": len(selected),
|
||||
"limit": max_records,
|
||||
"next_offset": len(selected),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _data(response: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
return _data(response)
|
||||
|
||||
@staticmethod
|
||||
def _single(response: Mapping[str, Any]) -> Any:
|
||||
data = response.get("data")
|
||||
if isinstance(data, list):
|
||||
return data[0] if data else None
|
||||
return data
|
||||
Reference in New Issue
Block a user