Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ Apollo stores notes in ProseMirror JSON format. This library automatically conve
- This once caused a wrong company to be attached to a deal + a duplicate account.
- `search_accounts` guards against it: it validates `**filters` against `ACCOUNT_SEARCH_FILTERS` (`q_organization_name`, `account_stage_ids`, `account_label_ids`, `sort_by_field`, `sort_ascending`) and raises `ValueError` on unknown keys. Same silent-drop risk applies to the other `search_*` methods — pass only documented filters.

**Search-filter formats (empirically verified — a wrong format is silently ignored or matches nothing, never an error):**
- **Deal name search is `q_opportunity_name`, NOT `q_keywords`.** Apollo silently ignores `q_keywords` on `/opportunities/search` (a nonsense keyword returns *every* deal). `q_opportunity_name` is in `DEAL_SEARCH_FILTERS`; `q_keywords` is not (passing it raises).
- **`organization_num_employees_ranges`** (people): `"min,max"` **comma** strings, e.g. `["1,10"]`, `["1000,5000"]`. A dash (`"1-10"`) is silently ignored (returns baseline).
- **`person_seniorities`** (people): lowercase enums — `owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern`. Uppercase / free text match 0 rows.
- **`person_locations` / `organization_locations`**: country name, 2-letter code (`"US"` == `"United States"`), or `"City, State, Country"`.
- **`revenue_range` / `organization_num_jobs_range`**: dict `{"min": int, "max": int}`. **`contact_email_status`**: `verified | unverified | likely to engage | unavailable`.
- **`contacts` `linkedin_url`**: must be Apollo's canonical form `http://www.linkedin.com/in/<slug>` (http, lowercased, url-encoded) or it matches nothing.
- Full per-filter formats live in each `search_*` docstring.

**`opportunities/update_roles` needs the role type NESTED under a `role` array:**
- Correct per-entry shape: `{"contact_id": …, "is_primary": …, "role": [{"opportunity_contact_role_type_id": …, "is_primary": …}]}`.
- Sending `opportunity_contact_role_type_id` flat on the entry (no `role` key) makes Apollo 422 with `undefined method 'map' for nil`.
Expand Down
76 changes: 67 additions & 9 deletions src/qodev_apollo_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@
)
DEAL_SEARCH_FILTERS = frozenset(
{
"q_keywords",
# Deal *name* search is q_opportunity_name — NOT q_keywords, which Apollo
# silently ignores for /opportunities/search (verified: a nonsense keyword
# still returned every deal).
"q_opportunity_name",
"opportunity_stage_ids",
"sort_by_field",
"sort_ascending",
Expand Down Expand Up @@ -290,7 +293,18 @@ async def search_contacts(
Args:
page: Page number (default 1)
limit: Results per page (default 100, max 100)
**filters: Additional filters (q_keywords, contact_stage_ids, linkedin_url, etc.)
**filters: Contact search filters (see ``CONTACT_SEARCH_FILTERS``):

- ``q_keywords`` (str): free-text over name / title / company / email.
- ``contact_stage_ids`` (list[str]): stage IDs (see ``get_contact_stages``).
- ``contact_label_ids`` (list[str]): label / list IDs.
- ``linkedin_url`` (str): must be Apollo's **canonical** form —
``http://www.linkedin.com/in/<slug>`` (http, lowercased,
url-encoded). A near-miss silently matches nothing.
- ``sort_by_field`` (str): ``contact_last_activity_date`` |
``contact_email_last_opened_at`` | ``contact_email_last_clicked_at`` |
``contact_created_at`` | ``contact_updated_at``.
- ``sort_ascending`` (bool).

Returns:
Paginated response with Contact items
Expand Down Expand Up @@ -448,10 +462,14 @@ async def search_accounts(
Args:
page: Page number (default 1)
limit: Results per page (default 100, max 100)
**filters: Search filters. Must be keys Apollo actually supports
(see ``ACCOUNT_SEARCH_FILTERS``): ``q_organization_name``,
``account_stage_ids``, ``account_label_ids``, ``sort_by_field``,
``sort_ascending``.
**filters: Account search filters (see ``ACCOUNT_SEARCH_FILTERS``):

- ``q_organization_name`` (str): company-name keyword.
- ``account_stage_ids`` (list[str]): account stage IDs.
- ``account_label_ids`` (list[str]): label / list IDs.
- ``sort_by_field`` (str): ``account_last_activity_date`` |
``account_created_at`` | ``account_updated_at``.
- ``sort_ascending`` (bool).

Returns:
Paginated response with Account items
Expand Down Expand Up @@ -500,7 +518,15 @@ async def search_deals(
Args:
page: Page number (default 1)
limit: Results per page (default 100, max 100)
**filters: Additional filters (opportunity_stage_ids, q_keywords, etc.)
**filters: Deal search filters (see ``DEAL_SEARCH_FILTERS``):

- ``q_opportunity_name`` (str): deal-name keyword. **Use this, not
``q_keywords``** — Apollo silently ignores ``q_keywords`` on
``/opportunities/search`` (it returns every deal).
- ``opportunity_stage_ids`` (list[str]): deal stage IDs (see
``list_all_stages``).
- ``sort_by_field`` (str): ``amount`` | ``is_closed`` | ``is_won``.
- ``sort_ascending`` (bool).

Returns:
Paginated response with Deal items
Expand Down Expand Up @@ -755,8 +781,40 @@ async def search_people(self, **filters) -> dict:
require a separate enrichment/reveal step and consume credits).

Args:
**filters: Search filters (q_keywords, person_titles, person_seniorities,
person_locations, q_organization_domains_list, etc.).
**filters: People search filters (see ``PEOPLE_SEARCH_FILTERS``).
Formats matter — a wrong format is silently ignored or matches
nothing rather than erroring:

- ``q_keywords`` (str): free text.
- ``person_titles`` (list[str]): job titles, e.g. ``["CEO", "VP Sales"]``.
``include_similar_titles`` (bool): broaden to related titles.
- ``person_seniorities`` (list[str]): **lowercase enums** —
``owner, founder, c_suite, partner, vp, head, director, manager,
senior, entry, intern``. Uppercase / free text (``"VP"``,
``"vice president"``) match **0** rows.
- ``person_locations`` / ``organization_locations`` (list[str]):
person / company location. Accepts a country name, a 2-letter
country code (``"US"`` == ``"United States"``), or
``"City, State, Country"``.
- ``organization_num_employees_ranges`` (list[str]): company-size
buckets as **"min,max"** strings, e.g. ``["1,10"]``,
``["1000,5000"]``. A dash (``"1-10"``) is **silently ignored**.
- ``q_organization_domains_list`` (list[str]): company domains,
e.g. ``["acme.com"]``. ``organization_ids`` (list[str]): Apollo org IDs.
- ``contact_email_status`` (list[str]): ``verified``, ``unverified``,
``likely to engage``, ``unavailable``.
- ``revenue_range`` / ``organization_num_jobs_range`` (dict):
``{"min": int, "max": int}``.
- ``organization_job_posted_at_range`` (dict): ``{"min": "YYYY-MM-DD",
"max": "YYYY-MM-DD"}``.
- ``currently_using_any_of_technology_uids`` /
``currently_using_all_of_technology_uids`` /
``currently_not_using_any_of_technology_uids`` (list[str]): tech
UIDs, e.g. ``["salesforce"]``.
- ``q_organization_job_titles`` (list[str]),
``organization_job_locations`` (list[str]).
- ``page`` / ``per_page`` (int): pagination (this method has no
explicit page/limit args — pass them as filters).

Returns:
Raw Apollo response dict: ``people`` (list) and ``total_entries`` (int).
Expand Down
14 changes: 14 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,20 @@ async def test_search_deals(client: ApolloClient):
assert isinstance(result.items[0], Deal)
assert result.items[0].name == "Big Deal"


async def test_search_deals_name_filter_is_q_opportunity_name(client: ApolloClient):
"""Deal name search is ``q_opportunity_name``; ``q_keywords`` is silently ignored
by Apollo for /opportunities/search, so it's not an accepted filter (raises)."""
client._client.request.return_value = _make_response(
{"opportunities": [], "pagination": {"total_entries": 0}}
)

await client.search_deals(q_opportunity_name="NORRIQ")
assert client._client.request.call_args[1]["json"]["q_opportunity_name"] == "NORRIQ"

with pytest.raises(ValueError, match="Unknown deal search filter"):
await client.search_deals(q_keywords="NORRIQ")

assert client._client.request.call_args[0] == ("POST", "/opportunities/search")


Expand Down
Loading