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