From 113f5d61dc918a22b298250ac5bceb373ad5e17f Mon Sep 17 00:00:00 2001 From: Joscha Feth Date: Tue, 23 Jul 2024 17:22:51 +0100 Subject: [PATCH 01/13] feat: persons endpoint --- README.md | 2 +- src/v1/organizations.ts | 225 +++++++++++++++++++++++++--------------- src/v1/persons.ts | 180 +++++++++++++++++++++++++++++++- src/v1/urls.ts | 14 +++ 4 files changed, 332 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index aeec465..7ca9375 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ examples, etc. - ✅ [Fields](src/v1/fields.ts) - ✅ [Field Values](src/v1/field_values.ts) - ✅ [Field Value Changes](src/v1/field_value_changes.ts) -- ❌ [Persons](src/v1/persons.ts) +- ✅ [Persons](src/v1/persons.ts) - ✅ [Organizations](src/v1/organizations.ts) - ❌ [Opportunities](src/v1/opportunities.ts) - ❌ Interactions diff --git a/src/v1/organizations.ts b/src/v1/organizations.ts index 2908164..c54604a 100644 --- a/src/v1/organizations.ts +++ b/src/v1/organizations.ts @@ -3,7 +3,7 @@ import { organizationFieldsUrl, organizationsUrl } from './urls.ts' import { defaultTransformers } from './axios_default_transformers.ts' import type { DateTime } from './field_values.ts' import type { ListEntryReferenceRaw } from './list_entries.ts' -import type { Person } from './persons.ts' +import type { PersonResponse as Person } from './persons.ts' import type { Opportunity } from './opportunities.ts' import type { Field } from './lists.ts' @@ -46,53 +46,99 @@ export type Organization = { global: boolean } -/** - * Each organization object has a unique id. It also has a name, domain (the website of the organization), and persons associated with it. - * The domain is an important attribute from an automation perspective, as it helps Affinity automatically link all the appropriate person objects to the organization. - * - * Each organization also has a flag determining whether it's global or not. - * As mentioned above, Affinity maintains its own database of global organizations that each customer has access to. - * Note that you cannot change the name or the domain of a global organization. - * You also cannot delete a global organization. - * - * Of course, if an organization is manually created by your team, all fields can be modified and the organization can be deleted. - * - * Dates of the most recent and upcoming interactions with an organization are available in the interaction_dates field. - * This data is only included when passing `with_interaction_dates=true` as a query parameter to the `GET /organizations` or the `GET /organizations/{organization_id}` endpoints. - */ -export type OrganizationResponseRaw = - & Organization +export type OpportunityIdResponseRaw = { + /** + * An array of unique identifiers of opportunities ({@link Opportunity.id}) that are associated with the entity. + * Only returned when passing `{@link OpportunitiesQueryParams.with_opportunities}=true`. + * + * TODO(@joscha): model this in the type system, so the return type is based on the query parameter type. + */ + opportunity_ids?: number[] +} + +type InteractionDateResponseBase = { + interaction_dates?: { + [key in InteractionDateKey]: never + } + interactions?: { + [key in InteractionType]: never + } +} + +export type InteractionDateResponseRaw = + & InteractionDateResponseBase & { /** - * An array of unique identifiers of people ({@link Person.id}) that are associated with the organization. + * An object with string date fields representing the most recent and upcoming interactions with this entity. + * Only returned when passing `{@link InteractionDatesQueryParams.with_interaction_dates}=true`. + * + * TODO(@joscha): model this in the type system, so the return type is based on the query parameter type. */ - person_ids?: number[] + interaction_dates?: { + [key in InteractionDateKey]: DateTime + } /** - * An array of unique identifiers of opportunities ({@link Opportunity.id}) that are associated with the organization. + * An object with seven fields nested underneath. + * Each field corresponds to one of the seven interactions, and includes nested fields for date and person_ids which indicates the internal people associated with that event (people only returned if passing `{@link InteractionDatesQueryParams.with_interaction_persons}=true`). + * Only returned when passing `{@link InteractionDatesQueryParams.with_interaction_dates}=true`. + * + * TODO(@joscha): model this in the type system, so the return type is based on the query parameter type. */ - opportunity_ids?: number[] + interactions?: { + [key in InteractionType]: InteractionDateRaw + } + } +export type InteractionDateResponse = + & InteractionDateResponseBase + & { /** - * An object with string date fields representing the most recent and upcoming interactions with this organization. - * Only returned when passing with_interaction_dates=true. + * An object with string date fields representing the most recent and upcoming interactions with this entity. + * Only returned when passing `{@link InteractionDatesQueryParams.with_interaction_dates}=true`. * * TODO(@joscha): model this in the type system, so the return type is based on the query parameter type. */ interaction_dates?: { - [key in InteractionDateKey]: DateTime + [key in InteractionDateKey]: Date } + /** * An object with seven fields nested underneath. * Each field corresponds to one of the seven interactions, and includes nested fields for date and person_ids which indicates the internal people associated with that event (people only returned if passing `{@link InteractionDatesQueryParams.with_interaction_persons}=true`). - * Only returned when passing `with_interaction_dates=true`. + * Only returned when passing `{@link InteractionDatesQueryParams.with_interaction_dates}=true`. * * TODO(@joscha): model this in the type system, so the return type is based on the query parameter type. */ interactions?: { - [key in InteractionType]: InteractionDateRaw + [key in InteractionType]: InteractionDate } } +/** + * Each organization object has a unique id. It also has a name, domain (the website of the organization), and persons associated with it. + * The domain is an important attribute from an automation perspective, as it helps Affinity automatically link all the appropriate person objects to the organization. + * + * Each organization also has a flag determining whether it's global or not. + * As mentioned above, Affinity maintains its own database of global organizations that each customer has access to. + * Note that you cannot change the name or the domain of a global organization. + * You also cannot delete a global organization. + * + * Of course, if an organization is manually created by your team, all fields can be modified and the organization can be deleted. + * + * Dates of the most recent and upcoming interactions with an organization are available in the interaction_dates field. + * This data is only included when passing `{@link InteractionDatesQueryParams.with_interaction_dates}=true` as a query parameter to the `GET /organizations` or the `GET /organizations/{organization_id}` endpoints. + */ +export type OrganizationResponseRaw = + & Organization + & OpportunityIdResponseRaw + & InteractionDateResponseRaw + & { + /** + * An array of unique identifiers of people ({@link Person.id}) that are associated with the organization. + */ + person_ids?: number[] + } + export type SimpleOrganizationResponse = & Organization & Pick< @@ -113,22 +159,23 @@ export type InteractionDate = Omit & { } export type OrganizationResponse = - & Omit - & { - interaction_dates?: { - [key in InteractionDateKey]: Date - } + & Omit + & InteractionDateResponse - interactions?: { - [key in InteractionType]: InteractionDate - } - } - -export type PagedOrganizationResponseRaw = { - organizations: OrganizationResponseRaw[] +export type PagedResponseRaw = { next_page_token: string | null } +export type ListEntryReference = Omit & { + created_at: Date +} + +export type PagedOrganizationResponseRaw = + & { + organizations: OrganizationResponseRaw[] + } + & PagedResponseRaw + export type PagedOrganizationResponse = & Omit & { @@ -144,10 +191,6 @@ export type SingleOrganizationResponseRaw = list_entries: ListEntryReferenceRaw[] } -export type ListEntryReference = Omit & { - created_at: Date -} - export type SingleOrganizationResponse = OrganizationResponse & { list_entries: ListEntryReference[] } @@ -218,26 +261,29 @@ export type OpportunitiesQueryParams = { with_opportunities?: boolean } +export type PagedRequest = { + /** + * The number of items to return per page. + * + * Default is the maximum value of 500. + */ + page_size?: number + + /** + * The page token to retrieve the next page of items. + * if you do not pass the `page_size` parameter, the next page will have the default page size of 500. + */ + page_token?: string +} + export type SearchOrganizationsRequest = & { /** * The search term to filter organizations. */ term?: string - - /** - * The number of organizations to return per page. - * - * Default is the maximum value of 500. - */ - page_size?: number - - /** - * The page token to retrieve the next page of organizations. - * if you do not pass the `page_size` parameter, the next page will have the default page size of 500. - */ - page_token?: string } + & PagedRequest & OpportunitiesQueryParams & OptionalMinQueryParams & OptionalMaxQueryParams @@ -314,7 +360,7 @@ export class Organizations { ): SingleOrganizationResponse => { const { list_entries, ...organization } = json return { - ...Organizations.transformOrganization( + ...transformInteractionDateResponseRaw( organization, ), list_entries: json.list_entries.map< @@ -362,8 +408,10 @@ export class Organizations { ): PagedOrganizationResponse => { return { ...json, - organizations: json.organizations.map( - Organizations.transformOrganization, + organizations: json.organizations.map< + OrganizationResponse + >( + transformInteractionDateResponseRaw, ), } }, @@ -373,34 +421,6 @@ export class Organizations { return response.data } - private static transformOrganization( - organization: OrganizationResponseRaw, - ): OrganizationResponse { - const { interaction_dates, interactions, ...rest } = organization - const ret: OrganizationResponse = { - ...rest, - } - if (interaction_dates) { - ret.interaction_dates = Object.fromEntries( - Object.entries(interaction_dates).map( - ([key, value]) => [key, new Date(value)], - ), - ) as Record - } - if (interactions) { - ret.interactions = Object.fromEntries( - Object.entries(interactions).map( - ([key, value]) => [key, { - ...value, - date: new Date(value.date), - }], - ), - ) as Record - } - - return ret - } - /** * Returns an async iterator that yields all organization entries matching the given search terms * Each yielded array contains up to the number specified in {@link SearchOrganizationsRequest.page_size} of organizations. @@ -532,3 +552,36 @@ export class Organizations { return response.data } } + +/** + * @hidden + */ +export function transformInteractionDateResponseRaw< + T extends InteractionDateResponseRaw, + U = Omit & InteractionDateResponse, +>( + entityWithInteractions: T, +): U { + const { interaction_dates, interactions, ...rest } = entityWithInteractions + const dates: InteractionDateResponse = {} + if (interaction_dates) { + dates.interaction_dates = Object.fromEntries( + Object.entries(interaction_dates).map( + ([key, value]) => [key, new Date(value)], + ), + ) as Record + } + if (interactions) { + dates.interactions = Object.fromEntries( + Object.entries(interactions).map( + ([key, value]) => [key, { + ...value, + date: new Date(value.date), + }], + ), + ) as Record + } + + // TODO(@joscha): fix the types so we don't need to cast here + return { ...rest, ...dates } as unknown as U +} diff --git a/src/v1/persons.ts b/src/v1/persons.ts index d9b047e..024837c 100644 --- a/src/v1/persons.ts +++ b/src/v1/persons.ts @@ -1,3 +1,24 @@ +import type { AxiosInstance } from 'axios' +import { + type InteractionDate, + type InteractionDateKey, + InteractionDateResponse, + type InteractionDateResponseRaw, + InteractionDatesQueryParams, + type InteractionType, + type ListEntryReference, + OpportunitiesQueryParams, + type OpportunityIdResponseRaw, + OptionalMaxQueryParams, + OptionalMinQueryParams, + PagedRequest, + PagedResponseRaw, + transformInteractionDateResponseRaw, +} from './organizations.ts' +import type { ListEntryReferenceRaw } from './list_entries.ts' +import { personsUrl } from './urls.ts' +import { defaultTransformers } from './axios_default_transformers.ts' + /** * The type of person. */ @@ -12,6 +33,161 @@ export enum PersonType { INTERNAL = 1, } -export type Person = { - id: number +/** + * Each person resource is assigned a unique `id` and stores the name, type, and email addresses of the person. A person resource also has access to a smart attribute called `primary_email`. The value of `primary_email` is automatically computed by Affinity's proprietary algorithms and refers to the email that is most likely to be the current active email address of a person. + * The person resource `organization_ids` is a collection of unique identifiers to the person's associated organizations. Note that a person can be associated with multiple organizations. For example, say your team has talked with organizations A and B. Person X used to work at A and was your point of contact, but then changed jobs and started emailing you from a new email address (corresponding to organization B). In this case, Affinity will automatically associate person X with both organization A and organization B. + * The person resource `type` indicates whether a person is internal or external to your team. Every internal person is a user of Affinity on your team, and all other people are externals. + * Dates of the most recent and upcoming interactions with a person are available in the `interaction_dates` field. This data is only included when passing `{@link InteractionDatesQueryParams.with_interaction_dates}=true` as a query parameter to the `/persons` or the `/persons/{person_id}` endpoints. + */ +export type PersonResponseRaw = + & { + /** The unique identifier of the person object. */ + id: number + /** The type of person. */ + type: PersonType + /** The first name of the person. */ + first_name: string + /** The last name of the person. */ + last_name: string + /** The email addresses of the person. */ + emails: string[] + /** The email (automatically computed) that is most likely to the current active email address of the person. */ + primary_email: string + /** An array of unique identifiers of organizations that the person is associated with. */ + organization_ids: number[] + + /** An array of unique identifiers of organizations that the person is currently associated with according to the Affinity Data: Current Organization in-app column. + * Only returned when `with_current_organizations=true`. + * + * TODO(@joscha): model this in the type system, so the return type is based on the query parameter type. + */ + current_organization_ids: number[] + } + & InteractionDateResponseRaw + & OpportunityIdResponseRaw + +export type PersonResponse = + & Omit + & InteractionDateResponse + +export type SinglePersonResponseRaw = + & PersonResponseRaw + & { + /** + * An array of list entry resources associated with the person, only returned as part of the {@link Persons.get} a specific person endpoint. + */ + list_entries: ListEntryReferenceRaw[] + } + +export type SearchPersonsRequest = + & { + /** + * The search term to filter persons. + * The search term can be part of an email address, a first name or a last name. + */ + term?: string + } + & PagedRequest + & OpportunitiesQueryParams + & OptionalMinQueryParams + & OptionalMaxQueryParams + & InteractionDatesQueryParams + +export type PagedPersonResponseRaw = + & { + persons: PersonResponseRaw[] + } + & PagedResponseRaw + +export type PagedPersonResponse = + & Omit + & { + persons: PersonResponse[] + } + +export type SingleOrganizationResponseRaw = + & { + /** + * An array of list entry resources associated with the person, only returned as part of the {@link Persons.get} a specific person endpoint. + */ + list_entries: ListEntryReferenceRaw[] + } + & PersonResponseRaw + +export type SinglePersonResponse = + & { + list_entries: ListEntryReference[] + } + & PersonResponse + +/** + * @module + * The persons API allows you to manage all the contacts of your organization. + * These people include anyone your team has ever been in email communications or meetings with, and all the people that your team has added to Affinity either manually or through the API. Affinity's data model also guarantees that only one person in your team's shared contact list has a given email address. + * + * *Notes*: + * - If you are looking to add or remove a person from a list, please check out the {@link ListEntries} section of the API. + * - If you are looking to modify a person's field values (one of the cells on Affinity's spreadsheet), please check out the {@link FieldValues} section of the API. + */ +export class Persons { + /** @hidden */ + constructor(private readonly axios: AxiosInstance) { + } + + private static transformSearchPersonsRequest( + data: SearchPersonsRequest, + ) { + return Object.fromEntries( + Object.entries(data).map(([key, value]) => [ + key, + value instanceof Date ? value.toISOString() : value, + ]), + ) + } + + /** + * Searches your teams data and fetches all the persons that meet the search criteria. + * + * This result is paginated. An initial request returns an object with two fields: `persons` and `next_page_token`. `persons` contains an array of person resources. The value of `next_page_token` should be sent as the query parameter `page_token` in another request to retrieve the next page of results. While paginating through results, each request must have identical query parameters other than the changing `page_token`. Otherwise, an `Invalid page_token variable` error will be returned. + * + * The absence of a `next_page_token` indicates that all the records have been fetched, though its presence does not necessarily indicate that there are *more* resources to be fetched. The next page may be empty (but then `next_page_token` would be `null` to confirm that there are no more resources). + * Pass `with_interaction_dates=true` as a query parameter to include dates of the most recent and upcoming interactions with persons. When this parameter is included, persons with no interactions will not be returned in the response. Pass `with_interaction_persons=true` as a query parameter if `with_interaction_dates=true` to also get the internal persons associated with the interaction. + * You can filter by interaction dates by providing additional query parameters like `min_last_email_date` or `max_next_event_date`. The value of these query parameters should be ISO 8601 formatted date strings. + * + * @param request - Object containing the data for the request + * + * @example + * ```typescript + * const result = await affinity.persons.search({ + * term: 'ben' + * }) + * console.log(result.primary_email) + * ``` + */ + async search( + request: SearchPersonsRequest, + ): Promise { + const response = await this.axios.get( + personsUrl(), + { + params: Persons.transformSearchPersonsRequest( + request, + ), + transformResponse: [ + ...defaultTransformers(), + ( + json: PagedPersonResponseRaw, + ): PagedPersonResponse => { + return { + ...json, + persons: json.persons.map( + transformInteractionDateResponseRaw, + ), + } + }, + ], + }, + ) + return response.data + } } diff --git a/src/v1/urls.ts b/src/v1/urls.ts index e4952f7..021afa6 100644 --- a/src/v1/urls.ts +++ b/src/v1/urls.ts @@ -66,3 +66,17 @@ export const organizationsUrl = (organization_id?: number | 'fields') => { * See [here](https://api-docs.affinity.co/#get-global-organizations-fields) for more info. */ export const organizationFieldsUrl = () => organizationsUrl('fields') + +/** + * @hidden + * See [here](https://api-docs.affinity.co/#persons) for more info. + */ +export const personsUrl = (person_id?: number | 'fields') => { + return person_id ? `/persons/${encodeURIComponent(person_id)}` : '/persons' +} + +/** + * @hidden + * See [here](https://api-docs.affinity.co/#get-global-person-fields) for more info. + */ +export const personFieldsUrl = () => personsUrl('fields') From 45556ec41c0cba392cd4f8a484c850fc532dc879 Mon Sep 17 00:00:00 2001 From: Joscha Feth Date: Tue, 23 Jul 2024 17:30:51 +0100 Subject: [PATCH 02/13] feat: persons endpoint; search iterator --- src/v1/persons.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/v1/persons.ts b/src/v1/persons.ts index 024837c..100b1aa 100644 --- a/src/v1/persons.ts +++ b/src/v1/persons.ts @@ -190,4 +190,42 @@ export class Persons { ) return response.data } + + /** + * Returns an async iterator that yields all person entries matching the given search terms + * Each yielded array contains up to the number specified in {@link SearchPersonsRequest.page_size} of persons. + * Use this method if you want to process the persons in a streaming fashion. + * + * *Please note:* the yielded persons array may be empty on the last page. + * + * @example + * ```typescript + * let page = 0 + * for await (const entries of affinity.persons.searchIterator({ + * term: 'ben', + * page_size: 10 + * })) { + * console.log(`Page ${++page} of entries:`, entries) + * } + * ``` + */ + async *searchIterator( + params: Omit, + ): AsyncGenerator { + let page_token: string | undefined = undefined + while (true) { + const response: PagedPersonResponse = await this.search( + page_token ? { ...params, page_token } : params, + ) + + yield response.persons + + if (response.next_page_token === null) { + // no more pages to fetch + return + } else { + page_token = response.next_page_token + } + } + } } From e9b0b590e255d6f119cc777783eb57ed6e04f747 Mon Sep 17 00:00:00 2001 From: Joscha Feth Date: Tue, 23 Jul 2024 17:33:05 +0100 Subject: [PATCH 03/13] style: clean types --- src/v1/organizations.ts | 8 +++----- src/v1/persons.ts | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/v1/organizations.ts b/src/v1/organizations.ts index c54604a..c836d23 100644 --- a/src/v1/organizations.ts +++ b/src/v1/organizations.ts @@ -58,10 +58,10 @@ export type OpportunityIdResponseRaw = { type InteractionDateResponseBase = { interaction_dates?: { - [key in InteractionDateKey]: never + [key in InteractionDateKey]: unknown } interactions?: { - [key in InteractionType]: never + [key in InteractionType]: unknown } } @@ -408,9 +408,7 @@ export class Organizations { ): PagedOrganizationResponse => { return { ...json, - organizations: json.organizations.map< - OrganizationResponse - >( + organizations: json.organizations.map( transformInteractionDateResponseRaw, ), } diff --git a/src/v1/persons.ts b/src/v1/persons.ts index 100b1aa..bf376dc 100644 --- a/src/v1/persons.ts +++ b/src/v1/persons.ts @@ -180,7 +180,7 @@ export class Persons { ): PagedPersonResponse => { return { ...json, - persons: json.persons.map( + persons: json.persons.map( transformInteractionDateResponseRaw, ), } From dd60999fa3b235f41f721152033dcae2014541fa Mon Sep 17 00:00:00 2001 From: Joscha Feth Date: Tue, 23 Jul 2024 17:40:40 +0100 Subject: [PATCH 04/13] style: clean types --- src/v1/list_entries.ts | 4 ++-- src/v1/organizations.ts | 6 +++--- src/v1/persons.ts | 21 ++++++--------------- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/v1/list_entries.ts b/src/v1/list_entries.ts index efc1e8d..c2e03c7 100644 --- a/src/v1/list_entries.ts +++ b/src/v1/list_entries.ts @@ -67,8 +67,8 @@ export type ListEntryResponseRaw = export type PagedListEntryResponseRaw = { list_entries: ListEntryResponseRaw[] /** - * The absence of a `next_page_token` indicates that all the records have been fetched, though its presence does not necessarily indicate that there are more resources to be fetched. - * The next page may be empty (but then `next_page_token` would be `null` to confirm that there are no more resources). + * The absence of a {@link PagedResponse.next_page_token} indicates that all the records have been fetched, though its presence does not necessarily indicate that there are more resources to be fetched. + * The next page may be empty (but then {@link PagedResponse.next_page_token} would be `null` to confirm that there are no more resources). */ next_page_token: string | null } diff --git a/src/v1/organizations.ts b/src/v1/organizations.ts index c836d23..8058951 100644 --- a/src/v1/organizations.ts +++ b/src/v1/organizations.ts @@ -162,7 +162,7 @@ export type OrganizationResponse = & Omit & InteractionDateResponse -export type PagedResponseRaw = { +export type PagedResponse = { next_page_token: string | null } @@ -174,7 +174,7 @@ export type PagedOrganizationResponseRaw = & { organizations: OrganizationResponseRaw[] } - & PagedResponseRaw + & PagedResponse export type PagedOrganizationResponse = & Omit @@ -249,7 +249,7 @@ export type InteractionDatesQueryParams = | { with_interaction_dates: true /** - * When true, persons for each interaction will be returned. Used in conjunction with `with_interaction_dates` + * When true, persons for each interaction will be returned. Used in conjunction with {@link InteractionDatesQueryParams.with_interaction_dates} */ with_interaction_persons: true } diff --git a/src/v1/persons.ts b/src/v1/persons.ts index bf376dc..966ac65 100644 --- a/src/v1/persons.ts +++ b/src/v1/persons.ts @@ -12,7 +12,7 @@ import { OptionalMaxQueryParams, OptionalMinQueryParams, PagedRequest, - PagedResponseRaw, + PagedResponse, transformInteractionDateResponseRaw, } from './organizations.ts' import type { ListEntryReferenceRaw } from './list_entries.ts' @@ -70,15 +70,6 @@ export type PersonResponse = & Omit & InteractionDateResponse -export type SinglePersonResponseRaw = - & PersonResponseRaw - & { - /** - * An array of list entry resources associated with the person, only returned as part of the {@link Persons.get} a specific person endpoint. - */ - list_entries: ListEntryReferenceRaw[] - } - export type SearchPersonsRequest = & { /** @@ -97,7 +88,7 @@ export type PagedPersonResponseRaw = & { persons: PersonResponseRaw[] } - & PagedResponseRaw + & PagedResponse export type PagedPersonResponse = & Omit @@ -105,7 +96,7 @@ export type PagedPersonResponse = persons: PersonResponse[] } -export type SingleOrganizationResponseRaw = +export type SinglePersonResponseRaw = & { /** * An array of list entry resources associated with the person, only returned as part of the {@link Persons.get} a specific person endpoint. @@ -148,10 +139,10 @@ export class Persons { /** * Searches your teams data and fetches all the persons that meet the search criteria. * - * This result is paginated. An initial request returns an object with two fields: `persons` and `next_page_token`. `persons` contains an array of person resources. The value of `next_page_token` should be sent as the query parameter `page_token` in another request to retrieve the next page of results. While paginating through results, each request must have identical query parameters other than the changing `page_token`. Otherwise, an `Invalid page_token variable` error will be returned. + * This result is paginated. An initial request returns an object with two fields: `persons` and {@link PagedResponse.next_page_token}. `persons` contains an array of person resources. The value of {@link PagedResponse.next_page_token} should be sent as the query parameter `page_token` in another request to retrieve the next page of results. While paginating through results, each request must have identical query parameters other than the changing `page_token`. Otherwise, an `Invalid page_token variable` error will be returned. * - * The absence of a `next_page_token` indicates that all the records have been fetched, though its presence does not necessarily indicate that there are *more* resources to be fetched. The next page may be empty (but then `next_page_token` would be `null` to confirm that there are no more resources). - * Pass `with_interaction_dates=true` as a query parameter to include dates of the most recent and upcoming interactions with persons. When this parameter is included, persons with no interactions will not be returned in the response. Pass `with_interaction_persons=true` as a query parameter if `with_interaction_dates=true` to also get the internal persons associated with the interaction. + * The absence of a {@link PagedResponse.next_page_token} indicates that all the records have been fetched, though its presence does not necessarily indicate that there are *more* resources to be fetched. The next page may be empty (but then {@link PagedResponse.next_page_token} would be `null` to confirm that there are no more resources). + * Pass `{@link InteractionDatesQueryParams.with_interaction_dates}=true` as a query parameter to include dates of the most recent and upcoming interactions with persons. When this parameter is included, persons with no interactions will not be returned in the response. Pass `with_interaction_persons=true` as a query parameter if `with_interaction_dates=true` to also get the internal persons associated with the interaction. * You can filter by interaction dates by providing additional query parameters like `min_last_email_date` or `max_next_event_date`. The value of these query parameters should be ISO 8601 formatted date strings. * * @param request - Object containing the data for the request From 7ee3be9c559d747ca79d550c66e4b5ee85fb9133 Mon Sep 17 00:00:00 2001 From: Joscha Feth Date: Tue, 23 Jul 2024 22:00:34 +0100 Subject: [PATCH 05/13] style: clean types; II --- src/v1/auth.ts | 21 +++--- src/v1/field_value_changes.ts | 10 ++- src/v1/field_values.ts | 7 +- src/v1/list_entries.ts | 16 ++--- src/v1/organizations.ts | 125 +++++++++++++++++----------------- src/v1/persons.ts | 28 +++----- src/v1/types.ts | 2 + 7 files changed, 101 insertions(+), 108 deletions(-) diff --git a/src/v1/auth.ts b/src/v1/auth.ts index a7a82fb..f65b6f7 100644 --- a/src/v1/auth.ts +++ b/src/v1/auth.ts @@ -2,6 +2,7 @@ import { AxiosInstance } from 'axios' import { enumFromValue } from './enum_from_value.ts' import { defaultTransformers } from './axios_default_transformers.ts' import { whoAmIUrl } from './urls.ts' +import type { DateTime, Replace } from './types.ts' /** * TODO(@joscha): Enum is most likely incomplete @@ -36,17 +37,13 @@ export type User = { email: string } -type WhoAmIResponseRaw = { - tenant: Tenant - user: User - grant: { - type: string - scope: string - createdAt: string - } +type GrantRaw = { + type: string + scope: string + createdAt: DateTime } -export type WhoAmIResponse = { +type WhoAmIResponseRaw = { /** * Information about the Affinity instance the user belongs to. */ @@ -58,12 +55,16 @@ export type WhoAmIResponse = { /** * Data about the type of authentication and metadata about the API key. */ + grant: GrantRaw +} + +export type WhoAmIResponse = Replace /** * @module diff --git a/src/v1/field_value_changes.ts b/src/v1/field_value_changes.ts index a68d8db..e64cfd9 100644 --- a/src/v1/field_value_changes.ts +++ b/src/v1/field_value_changes.ts @@ -1,7 +1,7 @@ import type { AxiosInstance } from 'axios' import type { Person } from './list_entries.ts' -import type { DateTime } from './types.ts' +import type { DateTime, Replace } from './types.ts' import { fieldValueChangesUrl } from './urls.ts' import { defaultTransformers } from './axios_default_transformers.ts' import type { Field } from './lists.ts' @@ -90,11 +90,9 @@ export type FieldValueChangeRaw = { export type FieldValueChangeResponseRaw = FieldValueChangeRaw[] -export type FieldValueChange = - & Omit - & { - changed_at: Date - } +export type FieldValueChange = Replace export type FieldValueChangeResponse = FieldValueChange[] diff --git a/src/v1/field_values.ts b/src/v1/field_values.ts index 08f0bd4..cebe3bc 100644 --- a/src/v1/field_values.ts +++ b/src/v1/field_values.ts @@ -3,7 +3,7 @@ import { fieldValuesUrl } from './urls.ts' import { defaultTransformers } from './axios_default_transformers.ts' import { FieldValueType } from './lists.ts' import { Field } from './lists.ts' -import type { DateTime } from './types.ts' +import type { DateTime, Replace } from './types.ts' import { FieldBase } from './fields.ts' export type { DateTime } from './types.ts' @@ -155,13 +155,14 @@ export type FieldValueRaw = export type FieldValueResponseRaw = FieldValueRaw[] -export type FieldValue = - & Omit +export type FieldValue = Replace< + FieldValueRaw, & { updated_at: Date | null created_at: Date } & ValueTypeMixin +> export type FieldValueResponse = FieldValue[] diff --git a/src/v1/list_entries.ts b/src/v1/list_entries.ts index c2e03c7..77ba908 100644 --- a/src/v1/list_entries.ts +++ b/src/v1/list_entries.ts @@ -2,7 +2,7 @@ import type { AxiosInstance } from 'axios' import type { EntityType, GetQuery } from './lists.ts' import { listEntriesUrl } from './urls.ts' import { defaultTransformers } from './axios_default_transformers.ts' -import type { DateTime } from './types.ts' +import type { DateTime, Replace } from './types.ts' import { PersonType } from './persons.ts' import { Organization } from './organizations.ts' @@ -52,7 +52,6 @@ export type ListEntryReferenceRaw = { } export type ListEntryResponseRaw = - & ListEntryReferenceRaw & { /** * The type of the entity corresponding to the list entry. @@ -63,6 +62,7 @@ export type ListEntryResponseRaw = */ entity: Entity } + & ListEntryReferenceRaw export type PagedListEntryResponseRaw = { list_entries: ListEntryResponseRaw[] @@ -73,15 +73,13 @@ export type PagedListEntryResponseRaw = { next_page_token: string | null } -export type ListEntryResponse = Omit & { +export type ListEntryResponse = Replace -export type PagedListEntryResponse = - & Omit - & { - list_entries: ListEntryResponse[] - } +export type PagedListEntryResponse = Replace /** * Paging parameters for retrieving list entries. diff --git a/src/v1/organizations.ts b/src/v1/organizations.ts index 8058951..d335425 100644 --- a/src/v1/organizations.ts +++ b/src/v1/organizations.ts @@ -6,6 +6,7 @@ import type { ListEntryReferenceRaw } from './list_entries.ts' import type { PersonResponse as Person } from './persons.ts' import type { Opportunity } from './opportunities.ts' import type { Field } from './lists.ts' +import type { Replace } from './types.ts' export type InteractionOccurrenceQuantifier = 'first' | 'last' @@ -58,61 +59,57 @@ export type OpportunityIdResponseRaw = { type InteractionDateResponseBase = { interaction_dates?: { - [key in InteractionDateKey]: unknown + [key in InteractionDateKey]: never } interactions?: { - [key in InteractionType]: unknown + [key in InteractionType]: never } } -export type InteractionDateResponseRaw = - & InteractionDateResponseBase - & { - /** - * An object with string date fields representing the most recent and upcoming interactions with this entity. - * Only returned when passing `{@link InteractionDatesQueryParams.with_interaction_dates}=true`. - * - * TODO(@joscha): model this in the type system, so the return type is based on the query parameter type. - */ - interaction_dates?: { - [key in InteractionDateKey]: DateTime - } - /** - * An object with seven fields nested underneath. - * Each field corresponds to one of the seven interactions, and includes nested fields for date and person_ids which indicates the internal people associated with that event (people only returned if passing `{@link InteractionDatesQueryParams.with_interaction_persons}=true`). - * Only returned when passing `{@link InteractionDatesQueryParams.with_interaction_dates}=true`. - * - * TODO(@joscha): model this in the type system, so the return type is based on the query parameter type. - */ - interactions?: { - [key in InteractionType]: InteractionDateRaw - } +export type InteractionDateResponseRaw = Replace -export type InteractionDateResponse = - & InteractionDateResponseBase - & { - /** - * An object with string date fields representing the most recent and upcoming interactions with this entity. - * Only returned when passing `{@link InteractionDatesQueryParams.with_interaction_dates}=true`. - * - * TODO(@joscha): model this in the type system, so the return type is based on the query parameter type. - */ - interaction_dates?: { - [key in InteractionDateKey]: Date - } +export type InteractionDateResponse = Replace /** * Each organization object has a unique id. It also has a name, domain (the website of the organization), and persons associated with it. @@ -154,21 +151,22 @@ export type InteractionDateRaw = { person_ids?: number[] } -export type InteractionDate = Omit & { +export type InteractionDate = Replace -export type OrganizationResponse = - & Omit - & InteractionDateResponse +export type OrganizationResponse = Replace< + OrganizationResponseRaw, + InteractionDateResponse +> export type PagedResponse = { next_page_token: string | null } -export type ListEntryReference = Omit & { +export type ListEntryReference = Replace export type PagedOrganizationResponseRaw = & { @@ -176,24 +174,24 @@ export type PagedOrganizationResponseRaw = } & PagedResponse -export type PagedOrganizationResponse = - & Omit - & { - organizations: OrganizationResponse[] - } +export type PagedOrganizationResponse = Replace export type SingleOrganizationResponseRaw = - & OrganizationResponseRaw & { /** * An array of list entry resources associated with the organization, only returned as part of the {@link Organizations.get} a specific organization endpoint. */ list_entries: ListEntryReferenceRaw[] } + & OrganizationResponseRaw -export type SingleOrganizationResponse = OrganizationResponse & { - list_entries: ListEntryReference[] -} +export type SingleOrganizationResponse = + & { + list_entries: ListEntryReference[] + } + & OrganizationResponse export type CreateOrganizationRequest = { /** @@ -211,7 +209,6 @@ export type CreateOrganizationRequest = { } export type UpdateOrganizationRequest = - & OrganizationReference & { /** * The name of the organization. @@ -227,6 +224,7 @@ export type UpdateOrganizationRequest = */ person_ids?: number[] } + & OrganizationReference export type InteractionTypeWithoutChat = Exclude< InteractionType, @@ -261,6 +259,7 @@ export type OpportunitiesQueryParams = { with_opportunities?: boolean } +// TODO(@joscha): see if we need to unify some of this with the `PagingParameters`. export type PagedRequest = { /** * The number of items to return per page. diff --git a/src/v1/persons.ts b/src/v1/persons.ts index 966ac65..14d40b0 100644 --- a/src/v1/persons.ts +++ b/src/v1/persons.ts @@ -1,11 +1,10 @@ import type { AxiosInstance } from 'axios' +import { defaultTransformers } from './axios_default_transformers.ts' +import type { ListEntryReferenceRaw } from './list_entries.ts' import { - type InteractionDate, - type InteractionDateKey, InteractionDateResponse, type InteractionDateResponseRaw, InteractionDatesQueryParams, - type InteractionType, type ListEntryReference, OpportunitiesQueryParams, type OpportunityIdResponseRaw, @@ -15,9 +14,8 @@ import { PagedResponse, transformInteractionDateResponseRaw, } from './organizations.ts' -import type { ListEntryReferenceRaw } from './list_entries.ts' import { personsUrl } from './urls.ts' -import { defaultTransformers } from './axios_default_transformers.ts' +import type { Replace } from './types.ts' /** * The type of person. @@ -61,14 +59,12 @@ export type PersonResponseRaw = * * TODO(@joscha): model this in the type system, so the return type is based on the query parameter type. */ - current_organization_ids: number[] + current_organization_ids?: number[] } & InteractionDateResponseRaw & OpportunityIdResponseRaw -export type PersonResponse = - & Omit - & InteractionDateResponse +export type PersonResponse = Replace export type SearchPersonsRequest = & { @@ -90,23 +86,21 @@ export type PagedPersonResponseRaw = } & PagedResponse -export type PagedPersonResponse = - & Omit - & { - persons: PersonResponse[] - } +export type PagedPersonResponse = Replace export type SinglePersonResponseRaw = & { - /** - * An array of list entry resources associated with the person, only returned as part of the {@link Persons.get} a specific person endpoint. - */ list_entries: ListEntryReferenceRaw[] } & PersonResponseRaw export type SinglePersonResponse = & { + /** + * An array of list entry resources associated with the person, only returned as part of the {@link Persons.get} a specific person endpoint. + */ list_entries: ListEntryReference[] } & PersonResponse diff --git a/src/v1/types.ts b/src/v1/types.ts index 4536c8b..1a20375 100644 --- a/src/v1/types.ts +++ b/src/v1/types.ts @@ -2,3 +2,5 @@ * This is a date string in the format ISO 8601 */ export type DateTime = string & { _datetimeBrand: never } + +export type Replace = Omit & T From 9ab4d71ef30289c6bf968f92d50254315b87fafa Mon Sep 17 00:00:00 2001 From: Joscha Feth Date: Wed, 24 Jul 2024 00:00:56 +0100 Subject: [PATCH 06/13] test: add test for persons endpoint --- src/v1/index.ts | 4 + .../tests/__snapshots__/persons_test.ts.snap | 43 +++++ .../fixtures/persons/search.raw.response.json | 35 ++++ src/v1/tests/persons_test.ts | 156 ++++++++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 src/v1/tests/__snapshots__/persons_test.ts.snap create mode 100644 src/v1/tests/fixtures/persons/search.raw.response.json create mode 100644 src/v1/tests/persons_test.ts diff --git a/src/v1/index.ts b/src/v1/index.ts index da991db..4c9ed08 100644 --- a/src/v1/index.ts +++ b/src/v1/index.ts @@ -6,6 +6,7 @@ import { Fields } from './fields.ts' import { FieldValues } from './field_values.ts' import { FieldValueChanges } from './field_value_changes.ts' import { Organizations } from './organizations.ts' +import { Persons } from './persons.ts' export type * as ListEntries from './list_entries.ts' export type * as Lists from './lists.ts' export type * as Fields from './fields.ts' @@ -51,6 +52,7 @@ export class Affinity { this.fieldValues = new FieldValues(this.axios) this.fieldValueChanges = new FieldValueChanges(this.axios) this.organizations = new Organizations(this.axios) + this.persons = new Persons(this.axios) } public readonly auth: Auth @@ -66,4 +68,6 @@ export class Affinity { public readonly fieldValueChanges: FieldValueChanges public readonly organizations: Organizations + + public readonly persons: Persons } diff --git a/src/v1/tests/__snapshots__/persons_test.ts.snap b/src/v1/tests/__snapshots__/persons_test.ts.snap new file mode 100644 index 0000000..04b2893 --- /dev/null +++ b/src/v1/tests/__snapshots__/persons_test.ts.snap @@ -0,0 +1,43 @@ +export const snapshot = {}; + +snapshot[`persons > can search for persons 1`] = ` +{ + next_page_token: null, + persons: [ + { + emails: [ + "j-m@p-a.com", + ], + first_name: "Joscha", + id: 198383654, + last_name: "Test", + primary_email: "j-m@p-a.com", + type: 0, + }, + { + emails: [ + "some@group.calendar.google.com", + ], + first_name: "Someone's", + id: 191703462, + last_name: "and Else's appointments", + primary_email: "some@group.calendar.google.com", + type: 0, + }, + { + emails: [ + "j@p-a.com", + "j@f.com", + "jf@j.com", + "j@c.com", + "jf@a.com", + ], + first_name: "Joscha", + id: 54576635, + last_name: "Feth", + primary_email: "j@p-a.com", + type: 1, + }, + ], +} +`; diff --git a/src/v1/tests/fixtures/persons/search.raw.response.json b/src/v1/tests/fixtures/persons/search.raw.response.json new file mode 100644 index 0000000..56c3b79 --- /dev/null +++ b/src/v1/tests/fixtures/persons/search.raw.response.json @@ -0,0 +1,35 @@ +{ + "next_page_token": null, + "persons": [ + { + "emails": ["j-m@p-a.com"], + "first_name": "Joscha", + "id": 198383654, + "last_name": "Test", + "primary_email": "j-m@p-a.com", + "type": 0 + }, + { + "emails": ["some@group.calendar.google.com"], + "first_name": "Someone's", + "id": 191703462, + "last_name": "and Else's appointments", + "primary_email": "some@group.calendar.google.com", + "type": 0 + }, + { + "emails": [ + "j@p-a.com", + "j@f.com", + "jf@j.com", + "j@c.com", + "jf@a.com" + ], + "first_name": "Joscha", + "id": 54576635, + "last_name": "Feth", + "primary_email": "j@p-a.com", + "type": 1 + } + ] +} diff --git a/src/v1/tests/persons_test.ts b/src/v1/tests/persons_test.ts new file mode 100644 index 0000000..0b185ff --- /dev/null +++ b/src/v1/tests/persons_test.ts @@ -0,0 +1,156 @@ +import { assertSnapshot } from '@std/testing/snapshot' +import { afterEach, beforeEach, describe, it } from '@std/testing/bdd' + +import axios from 'axios' +import MockAdapter from 'axios-mock-adapter' +import { Affinity } from '../index.ts' +import { getRawFixture } from './get_raw_fixture.ts' +import { apiKey, isLiveRun } from './env.ts' +import { personFieldsUrl, personsUrl } from '../urls.ts' +import type { SearchPersonsRequest } from '../persons.ts' + +describe('persons', () => { + let mock: MockAdapter + let affinity: Affinity + + beforeEach(() => { + if (!isLiveRun()) { + mock = new MockAdapter(axios, { onNoMatch: 'throwException' }) + } + affinity = new Affinity(apiKey() || 'api_key') + }) + afterEach(() => { + mock?.reset() + }) + + // it('can get a specific person', async (t) => { + // const person_id = 64779194 + // mock?.onGet(personsUrl(person_id)).reply( + // 200, + // await getRawFixture('persons/get.raw.response.json'), + // ) + // const res = await affinity.persons.get({ person_id }) + // await assertSnapshot(t, res) + // }) + + it('can search for persons', async (t) => { + const request = { term: 'joscha' } + mock?.onGet(personsUrl(), { params: request }).reply( + 200, + await getRawFixture('persons/search.raw.response.json'), + ) + const res = await affinity.persons.search(request) + await assertSnapshot(t, res) + }) + + // it('can search for persons with the appropriate dates', async (t) => { + // const myDate = new Date(1717428411010) + // const request: SearchPersonsRequest = { + // min_first_email_date: myDate, + // term: 'joscha', + // } + // mock?.onGet(personsUrl(), { + // params: { + // term: request.term, + // min_first_email_date: myDate.toISOString(), + // }, + // }).reply( + // 200, + // await getRawFixture('persons/search.raw.response.json'), + // ) + // const res = await affinity.persons.search(request) + // await assertSnapshot(t, res) + // }) + + // it('can create a new person', async (t) => { + // const data = { + // name: 'Acme Corporation', + // domain: 'acme.co', + // person_ids: [38706], + // } + // mock?.onPost(personsUrl()).reply( + // 201, + // await getRawFixture('persons/create.raw.response.json'), + // ) + // const res = await affinity.persons.create(data) + // await assertSnapshot(t, res) + // }) + + // it('can update an person', async (t) => { + // const data = { + // person_id: 120611418, + // name: 'Acme Corp.', + // person_ids: [38706, 89734], + // } + // mock?.onPut(personsUrl(data.person_id)).reply( + // 200, + // await getRawFixture('persons/update.raw.response.json'), + // ) + // const res = await affinity.persons.update(data) + // await assertSnapshot(t, res) + // }) + + // it('can delete an person', async (t) => { + // const person_id = 120611418 + // mock?.onDelete(personsUrl(person_id)).reply(200, { + // success: true, + // }) + // const res = await affinity.persons.delete({ person_id }) + // await assertSnapshot(t, res) + // }) + + // it('can get global person fields', async (t) => { + // mock?.onGet(personFieldsUrl()).reply( + // 200, + // await getRawFixture('persons/get_fields.raw.response.json'), + // ) + // const res = await affinity.persons.getFields() + // await assertSnapshot(t, res) + // }) + + // it('iterates over all persons', async (t) => { + // const params: SearchPersonsRequest = { + // term: 'fridel', + // page_size: 1, + // } + + // { + // // set up pages sequentially, each referencing the one after + // const { default: pages } = await import( + // './fixtures/persons/paginated.iterator.combined.response.json', + // { + // with: { + // type: 'json', + // }, + // } + // ) + + // pages.forEach((page, i) => { + // const { next_page_token: previous_page_token } = pages[i - 1] || + // {} + // const data: SearchPersonsRequest = { + // ...params, + // } + // if (previous_page_token) { + // data.page_token = previous_page_token + // } + // // console.log('Setting up page', params, page.list_entries) + // mock?.onGet(personsUrl(), { + // params: data, + // }).reply( + // 200, + // page, + // ) + // }) + // } + + // let page = 0 + // for await ( + // const entries of affinity.persons.searchIterator(params) + // ) { + // await assertSnapshot(t, entries, { + // name: `page ${++page} of persons`, + // }) + // } + // }) +}) From 668e280237dcaa2a78f4edcb375bc62eb08d4645 Mon Sep 17 00:00:00 2001 From: Joscha Feth Date: Thu, 25 Jul 2024 16:36:07 +0100 Subject: [PATCH 07/13] refactor: search iterator HOC --- src/v1/create_search_iterator_fn.ts | 39 ++++++++++++++++++++++ src/v1/field_value_changes.ts | 19 +++-------- src/v1/organizations.ts | 52 ++++++----------------------- src/v1/paged_request.ts | 16 +++++++++ src/v1/paged_response.ts | 3 ++ src/v1/persons.ts | 37 ++++++++------------ src/v1/types.ts | 11 ++++++ 7 files changed, 97 insertions(+), 80 deletions(-) create mode 100644 src/v1/create_search_iterator_fn.ts create mode 100644 src/v1/paged_request.ts create mode 100644 src/v1/paged_response.ts diff --git a/src/v1/create_search_iterator_fn.ts b/src/v1/create_search_iterator_fn.ts new file mode 100644 index 0000000..34b4eaa --- /dev/null +++ b/src/v1/create_search_iterator_fn.ts @@ -0,0 +1,39 @@ +import type { PagedResponse } from './paged_response.ts' +import type { PagedRequest } from './paged_request.ts' + +export const createSearchIteratorFn = < + FN extends (r: PAGED_REQUEST) => Promise, + PAGED_REQUEST extends PagedRequest, + PAGED_RESPONSE extends + & PagedResponse + & Record, + PAYLOAD_KEY extends string = + & keyof Omit + & string, + SINGLE_RESPONSE = object, +>(searchFn: FN, key: PAYLOAD_KEY) => { + async function* searchIterator( + params: Omit, + ): AsyncGenerator { + let page_token: string | undefined = undefined + while (true) { + const response: PAGED_RESPONSE = await searchFn( + // TODO(@joscha): remove cast + (page_token + ? { ...params, page_token } + : params) as PAGED_REQUEST, + ) + + // TODO(@joscha): remove cast + yield response[key] as unknown as PAGED_RESPONSE[] + + if (response.next_page_token === null) { + // no more pages to fetch + return + } else { + page_token = response.next_page_token + } + } + } + return searchIterator +} diff --git a/src/v1/field_value_changes.ts b/src/v1/field_value_changes.ts index e64cfd9..7ab250a 100644 --- a/src/v1/field_value_changes.ts +++ b/src/v1/field_value_changes.ts @@ -1,22 +1,11 @@ import type { AxiosInstance } from 'axios' -import type { Person } from './list_entries.ts' -import type { DateTime, Replace } from './types.ts' -import { fieldValueChangesUrl } from './urls.ts' import { defaultTransformers } from './axios_default_transformers.ts' -import type { Field } from './lists.ts' import type { Value, ValueRaw } from './field_values.ts' - -/** - * Via https://stackoverflow.com/questions/40510611 - */ -export type RequireOnlyOne = - & Pick> - & { - [K in Keys]-?: - & Required> - & Partial, never>> - }[Keys] +import type { Person } from './list_entries.ts' +import type { Field } from './lists.ts' +import type { DateTime, Replace, RequireOnlyOne } from './types.ts' +import { fieldValueChangesUrl } from './urls.ts' /** * Enum for Action Type. diff --git a/src/v1/organizations.ts b/src/v1/organizations.ts index d335425..865538b 100644 --- a/src/v1/organizations.ts +++ b/src/v1/organizations.ts @@ -1,12 +1,15 @@ import type { AxiosInstance } from 'axios' -import { organizationFieldsUrl, organizationsUrl } from './urls.ts' import { defaultTransformers } from './axios_default_transformers.ts' +import { createSearchIteratorFn } from './create_search_iterator_fn.ts' import type { DateTime } from './field_values.ts' import type { ListEntryReferenceRaw } from './list_entries.ts' -import type { PersonResponse as Person } from './persons.ts' -import type { Opportunity } from './opportunities.ts' import type { Field } from './lists.ts' +import type { Opportunity } from './opportunities.ts' +import type { PagedRequest } from './paged_request.ts' +import type { PagedResponse } from './paged_response.ts' +import type { PersonResponse as Person } from './persons.ts' import type { Replace } from './types.ts' +import { organizationFieldsUrl, organizationsUrl } from './urls.ts' export type InteractionOccurrenceQuantifier = 'first' | 'last' @@ -160,10 +163,6 @@ export type OrganizationResponse = Replace< InteractionDateResponse > -export type PagedResponse = { - next_page_token: string | null -} - export type ListEntryReference = Replace @@ -259,22 +258,6 @@ export type OpportunitiesQueryParams = { with_opportunities?: boolean } -// TODO(@joscha): see if we need to unify some of this with the `PagingParameters`. -export type PagedRequest = { - /** - * The number of items to return per page. - * - * Default is the maximum value of 500. - */ - page_size?: number - - /** - * The page token to retrieve the next page of items. - * if you do not pass the `page_size` parameter, the next page will have the default page size of 500. - */ - page_token?: string -} - export type SearchOrganizationsRequest = & { /** @@ -436,25 +419,10 @@ export class Organizations { * } * ``` */ - async *searchIterator( - params: Omit, - ): AsyncGenerator { - let page_token: string | undefined = undefined - while (true) { - const response: PagedOrganizationResponse = await this.search( - page_token ? { ...params, page_token } : params, - ) - - yield response.organizations - - if (response.next_page_token === null) { - // no more pages to fetch - return - } else { - page_token = response.next_page_token - } - } - } + searchIterator = createSearchIteratorFn( + this.search.bind(this), + 'organizations', + ) /** * Creates a new organization with the supplied parameters. diff --git a/src/v1/paged_request.ts b/src/v1/paged_request.ts new file mode 100644 index 0000000..d042cb2 --- /dev/null +++ b/src/v1/paged_request.ts @@ -0,0 +1,16 @@ +// TODO(@joscha): see if we need to unify some of this with the `PagingParameters`. + +export type PagedRequest = { + /** + * The number of items to return per page. + * + * Default is the maximum value of 500. + */ + page_size?: number + + /** + * The page token to retrieve the next page of items. + * if you do not pass the `page_size` parameter, the next page will have the default page size of 500. + */ + page_token?: string +} diff --git a/src/v1/paged_response.ts b/src/v1/paged_response.ts new file mode 100644 index 0000000..d5ebe7d --- /dev/null +++ b/src/v1/paged_response.ts @@ -0,0 +1,3 @@ +export type PagedResponse = { + next_page_token: string | null +} diff --git a/src/v1/persons.ts b/src/v1/persons.ts index 14d40b0..3f1cfbf 100644 --- a/src/v1/persons.ts +++ b/src/v1/persons.ts @@ -1,5 +1,6 @@ import type { AxiosInstance } from 'axios' import { defaultTransformers } from './axios_default_transformers.ts' +import { createSearchIteratorFn } from './create_search_iterator_fn.ts' import type { ListEntryReferenceRaw } from './list_entries.ts' import { InteractionDateResponse, @@ -10,12 +11,12 @@ import { type OpportunityIdResponseRaw, OptionalMaxQueryParams, OptionalMinQueryParams, - PagedRequest, - PagedResponse, transformInteractionDateResponseRaw, } from './organizations.ts' -import { personsUrl } from './urls.ts' +import type { PagedRequest } from './paged_request.ts' +import type { PagedResponse } from './paged_response.ts' import type { Replace } from './types.ts' +import { personsUrl } from './urls.ts' /** * The type of person. @@ -55,7 +56,7 @@ export type PersonResponseRaw = organization_ids: number[] /** An array of unique identifiers of organizations that the person is currently associated with according to the Affinity Data: Current Organization in-app column. - * Only returned when `with_current_organizations=true`. + * Only returned when `{@link WithCurrentOrganizatonParams.with_current_organizations}=true`. * * TODO(@joscha): model this in the type system, so the return type is based on the query parameter type. */ @@ -66,6 +67,13 @@ export type PersonResponseRaw = export type PersonResponse = Replace +export type WithCurrentOrganizatonParams = { + /** + * When true, the organization IDs of each person's current organizations (according to the Affinity Data: Current Organizations column) will be returned. + */ + with_current_organizations?: boolean +} + export type SearchPersonsRequest = & { /** @@ -79,6 +87,7 @@ export type SearchPersonsRequest = & OptionalMinQueryParams & OptionalMaxQueryParams & InteractionDatesQueryParams + & WithCurrentOrganizatonParams export type PagedPersonResponseRaw = & { @@ -194,23 +203,5 @@ export class Persons { * } * ``` */ - async *searchIterator( - params: Omit, - ): AsyncGenerator { - let page_token: string | undefined = undefined - while (true) { - const response: PagedPersonResponse = await this.search( - page_token ? { ...params, page_token } : params, - ) - - yield response.persons - - if (response.next_page_token === null) { - // no more pages to fetch - return - } else { - page_token = response.next_page_token - } - } - } + searchIterator = createSearchIteratorFn(this.search.bind(this), 'persons') } diff --git a/src/v1/types.ts b/src/v1/types.ts index 1a20375..ebd6cfd 100644 --- a/src/v1/types.ts +++ b/src/v1/types.ts @@ -4,3 +4,14 @@ export type DateTime = string & { _datetimeBrand: never } export type Replace = Omit & T + +/** + * Via https://stackoverflow.com/questions/40510611 + */ +export type RequireOnlyOne = + & Pick> + & { + [K in Keys]-?: + & Required> + & Partial, never>> + }[Keys] From 180eb4f61b7b61d59b6eb4b808193b10b9a36906 Mon Sep 17 00:00:00 2001 From: Joscha Feth Date: Thu, 25 Jul 2024 16:51:15 +0100 Subject: [PATCH 08/13] test: persons iterator test --- .../tests/__snapshots__/persons_test.ts.snap | 51 +++++++++++ .../paginated.iterator.combined.response.json | 51 +++++++++++ src/v1/tests/persons_test.ts | 84 +++++++++---------- 3 files changed, 144 insertions(+), 42 deletions(-) create mode 100644 src/v1/tests/fixtures/persons/paginated.iterator.combined.response.json diff --git a/src/v1/tests/__snapshots__/persons_test.ts.snap b/src/v1/tests/__snapshots__/persons_test.ts.snap index 04b2893..55aa033 100644 --- a/src/v1/tests/__snapshots__/persons_test.ts.snap +++ b/src/v1/tests/__snapshots__/persons_test.ts.snap @@ -41,3 +41,54 @@ snapshot[`persons > can search for persons 1`] = ` ], } `; + +snapshot[`page 1 of persons 1`] = ` +[ + { + emails: [ + "j-m@p-a.com", + ], + first_name: "Joscha", + id: 198383654, + last_name: "Test", + primary_email: "j-m@p-a.com", + type: 0, + }, +] +`; + +snapshot[`page 2 of persons 1`] = ` +[ + { + emails: [ + "some@group.calendar.google.com", + ], + first_name: "Someone's", + id: 191703462, + last_name: "and Else's appointments", + primary_email: "some@group.calendar.google.com", + type: 0, + }, +] +`; + +snapshot[`page 3 of persons 1`] = ` +[ + { + emails: [ + "j@p-a.com", + "j@f.com", + "jf@j.com", + "j@c.com", + "jf@a.com", + ], + first_name: "Joscha", + id: 54576635, + last_name: "Feth", + primary_email: "j@p-a.com", + type: 1, + }, +] +`; + +snapshot[`page 4 of persons 1`] = `[]`; diff --git a/src/v1/tests/fixtures/persons/paginated.iterator.combined.response.json b/src/v1/tests/fixtures/persons/paginated.iterator.combined.response.json new file mode 100644 index 0000000..5b44eda --- /dev/null +++ b/src/v1/tests/fixtures/persons/paginated.iterator.combined.response.json @@ -0,0 +1,51 @@ +[ + { + "next_page_token": "A", + "persons": [ + { + "emails": ["j-m@p-a.com"], + "first_name": "Joscha", + "id": 198383654, + "last_name": "Test", + "primary_email": "j-m@p-a.com", + "type": 0 + } + ] + }, + { + "next_page_token": "B", + "persons": [ + { + "emails": ["some@group.calendar.google.com"], + "first_name": "Someone's", + "id": 191703462, + "last_name": "and Else's appointments", + "primary_email": "some@group.calendar.google.com", + "type": 0 + } + ] + }, + { + "next_page_token": "C", + "persons": [ + { + "emails": [ + "j@p-a.com", + "j@f.com", + "jf@j.com", + "j@c.com", + "jf@a.com" + ], + "first_name": "Joscha", + "id": 54576635, + "last_name": "Feth", + "primary_email": "j@p-a.com", + "type": 1 + } + ] + }, + { + "next_page_token": null, + "persons": [] + } +] diff --git a/src/v1/tests/persons_test.ts b/src/v1/tests/persons_test.ts index 0b185ff..978d5b9 100644 --- a/src/v1/tests/persons_test.ts +++ b/src/v1/tests/persons_test.ts @@ -108,49 +108,49 @@ describe('persons', () => { // await assertSnapshot(t, res) // }) - // it('iterates over all persons', async (t) => { - // const params: SearchPersonsRequest = { - // term: 'fridel', - // page_size: 1, - // } + it('iterates over all persons', async (t) => { + const params: SearchPersonsRequest = { + term: 'joscha', + page_size: 1, + } - // { - // // set up pages sequentially, each referencing the one after - // const { default: pages } = await import( - // './fixtures/persons/paginated.iterator.combined.response.json', - // { - // with: { - // type: 'json', - // }, - // } - // ) + { + // set up pages sequentially, each referencing the one after + const { default: pages } = await import( + './fixtures/persons/paginated.iterator.combined.response.json', + { + with: { + type: 'json', + }, + } + ) - // pages.forEach((page, i) => { - // const { next_page_token: previous_page_token } = pages[i - 1] || - // {} - // const data: SearchPersonsRequest = { - // ...params, - // } - // if (previous_page_token) { - // data.page_token = previous_page_token - // } - // // console.log('Setting up page', params, page.list_entries) - // mock?.onGet(personsUrl(), { - // params: data, - // }).reply( - // 200, - // page, - // ) - // }) - // } + pages.forEach((page, i) => { + const { next_page_token: previous_page_token } = pages[i - 1] || + {} + const data: SearchPersonsRequest = { + ...params, + } + if (previous_page_token) { + data.page_token = previous_page_token + } + // console.log('Setting up page', params, page.list_entries) + mock?.onGet(personsUrl(), { + params: data, + }).reply( + 200, + page, + ) + }) + } - // let page = 0 - // for await ( - // const entries of affinity.persons.searchIterator(params) - // ) { - // await assertSnapshot(t, entries, { - // name: `page ${++page} of persons`, - // }) - // } - // }) + let page = 0 + for await ( + const entries of affinity.persons.searchIterator(params) + ) { + await assertSnapshot(t, entries, { + name: `page ${++page} of persons`, + }) + } + }) }) From bfe25e1538242c57ab011ad65e7771fc46022f84 Mon Sep 17 00:00:00 2001 From: Joscha Feth Date: Thu, 25 Jul 2024 16:58:45 +0100 Subject: [PATCH 09/13] refactor: move fn --- src/v1/organizations.ts | 36 +--------------- src/v1/persons.ts | 2 +- ...transform_interaction_date_response_raw.ts | 42 +++++++++++++++++++ 3 files changed, 45 insertions(+), 35 deletions(-) create mode 100644 src/v1/transform_interaction_date_response_raw.ts diff --git a/src/v1/organizations.ts b/src/v1/organizations.ts index 865538b..8608e56 100644 --- a/src/v1/organizations.ts +++ b/src/v1/organizations.ts @@ -10,6 +10,7 @@ import type { PagedResponse } from './paged_response.ts' import type { PersonResponse as Person } from './persons.ts' import type { Replace } from './types.ts' import { organizationFieldsUrl, organizationsUrl } from './urls.ts' +import { transformInteractionDateResponseRaw } from './transform_interaction_date_response_raw.ts' export type InteractionOccurrenceQuantifier = 'first' | 'last' @@ -60,7 +61,7 @@ export type OpportunityIdResponseRaw = { opportunity_ids?: number[] } -type InteractionDateResponseBase = { +export type InteractionDateResponseBase = { interaction_dates?: { [key in InteractionDateKey]: never } @@ -517,36 +518,3 @@ export class Organizations { return response.data } } - -/** - * @hidden - */ -export function transformInteractionDateResponseRaw< - T extends InteractionDateResponseRaw, - U = Omit & InteractionDateResponse, ->( - entityWithInteractions: T, -): U { - const { interaction_dates, interactions, ...rest } = entityWithInteractions - const dates: InteractionDateResponse = {} - if (interaction_dates) { - dates.interaction_dates = Object.fromEntries( - Object.entries(interaction_dates).map( - ([key, value]) => [key, new Date(value)], - ), - ) as Record - } - if (interactions) { - dates.interactions = Object.fromEntries( - Object.entries(interactions).map( - ([key, value]) => [key, { - ...value, - date: new Date(value.date), - }], - ), - ) as Record - } - - // TODO(@joscha): fix the types so we don't need to cast here - return { ...rest, ...dates } as unknown as U -} diff --git a/src/v1/persons.ts b/src/v1/persons.ts index 3f1cfbf..2450884 100644 --- a/src/v1/persons.ts +++ b/src/v1/persons.ts @@ -11,8 +11,8 @@ import { type OpportunityIdResponseRaw, OptionalMaxQueryParams, OptionalMinQueryParams, - transformInteractionDateResponseRaw, } from './organizations.ts' +import { transformInteractionDateResponseRaw } from './transform_interaction_date_response_raw.ts' import type { PagedRequest } from './paged_request.ts' import type { PagedResponse } from './paged_response.ts' import type { Replace } from './types.ts' diff --git a/src/v1/transform_interaction_date_response_raw.ts b/src/v1/transform_interaction_date_response_raw.ts new file mode 100644 index 0000000..cf0ea62 --- /dev/null +++ b/src/v1/transform_interaction_date_response_raw.ts @@ -0,0 +1,42 @@ +import { + InteractionDate, + InteractionDateKey, + InteractionDateResponse, + InteractionDateResponseBase, + InteractionDateResponseRaw, + InteractionType, +} from './organizations.ts' + +/** + * @hidden + */ + +export function transformInteractionDateResponseRaw< + T extends InteractionDateResponseRaw, + U = Omit & InteractionDateResponse, +>( + entityWithInteractions: T, +): U { + const { interaction_dates, interactions, ...rest } = entityWithInteractions + const dates: InteractionDateResponse = {} + if (interaction_dates) { + dates.interaction_dates = Object.fromEntries( + Object.entries(interaction_dates).map( + ([key, value]) => [key, new Date(value)], + ), + ) as Record + } + if (interactions) { + dates.interactions = Object.fromEntries( + Object.entries(interactions).map( + ([key, value]) => [key, { + ...value, + date: new Date(value.date), + }], + ), + ) as Record + } + + // TODO(@joscha): fix the types so we don't need to cast here + return { ...rest, ...dates } as unknown as U +} From a2f6d24ede303495b1615fd5440b8e187c685c77 Mon Sep 17 00:00:00 2001 From: Joscha Feth Date: Thu, 25 Jul 2024 23:49:01 +0100 Subject: [PATCH 10/13] feat: persons endpoint; single get --- src/v1/list_entries.ts | 22 ++----- src/v1/organizations.ts | 12 ++-- src/v1/persons.ts | 57 ++++++++++++++++++- .../tests/__snapshots__/persons_test.ts.snap | 43 ++++++++++++++ .../fixtures/persons/get.raw.response.json | 40 +++++++++++++ src/v1/tests/persons_test.ts | 22 +++---- src/v1/transform_list_entry_reference.ts | 12 ++++ 7 files changed, 171 insertions(+), 37 deletions(-) create mode 100644 src/v1/tests/fixtures/persons/get.raw.response.json create mode 100644 src/v1/transform_list_entry_reference.ts diff --git a/src/v1/list_entries.ts b/src/v1/list_entries.ts index 77ba908..e41ede1 100644 --- a/src/v1/list_entries.ts +++ b/src/v1/list_entries.ts @@ -5,6 +5,7 @@ import { defaultTransformers } from './axios_default_transformers.ts' import type { DateTime, Replace } from './types.ts' import { PersonType } from './persons.ts' import { Organization } from './organizations.ts' +import { transformListEntryReference } from './transform_list_entry_reference.ts' export type Person = { id: number @@ -142,15 +143,6 @@ export class ListEntries { constructor(private readonly axios: AxiosInstance) { } - private static transformEntry = ( - entry: ListEntryResponseRaw, - ): ListEntryResponse => { - return { - ...entry, - created_at: new Date(entry.created_at), - } - } - /** * Fetches all list entries in the list with the supplied list id. * @@ -213,11 +205,11 @@ export class ListEntries { return { ...json, list_entries: json.list_entries.map( - ListEntries.transformEntry, + transformListEntryReference, ), } } else { - return json.map(ListEntries.transformEntry) + return json.map(transformListEntryReference) } }, ], @@ -246,9 +238,7 @@ export class ListEntries { { transformResponse: [ ...defaultTransformers(), - (json: ListEntryResponseRaw) => { - return ListEntries.transformEntry(json) - }, + transformListEntryReference, ], }, ) @@ -345,9 +335,7 @@ export class ListEntries { { transformResponse: [ ...defaultTransformers(), - (json: ListEntryResponseRaw) => { - return ListEntries.transformEntry(json) - }, + transformListEntryReference, ], }, ) diff --git a/src/v1/organizations.ts b/src/v1/organizations.ts index 8608e56..c63942e 100644 --- a/src/v1/organizations.ts +++ b/src/v1/organizations.ts @@ -11,6 +11,7 @@ import type { PersonResponse as Person } from './persons.ts' import type { Replace } from './types.ts' import { organizationFieldsUrl, organizationsUrl } from './urls.ts' import { transformInteractionDateResponseRaw } from './transform_interaction_date_response_raw.ts' +import { transformListEntryReference } from './transform_list_entry_reference.ts' export type InteractionOccurrenceQuantifier = 'first' | 'last' @@ -346,14 +347,9 @@ export class Organizations { ...transformInteractionDateResponseRaw( organization, ), - list_entries: json.list_entries.map< - ListEntryReference - >((entry) => { - return { - ...entry, - created_at: new Date(entry.created_at), - } - }), + list_entries: json.list_entries.map( + transformListEntryReference, + ), } }, ], diff --git a/src/v1/persons.ts b/src/v1/persons.ts index 2450884..c615b0d 100644 --- a/src/v1/persons.ts +++ b/src/v1/persons.ts @@ -12,9 +12,10 @@ import { OptionalMaxQueryParams, OptionalMinQueryParams, } from './organizations.ts' -import { transformInteractionDateResponseRaw } from './transform_interaction_date_response_raw.ts' import type { PagedRequest } from './paged_request.ts' import type { PagedResponse } from './paged_response.ts' +import { transformInteractionDateResponseRaw } from './transform_interaction_date_response_raw.ts' +import { transformListEntryReference } from './transform_list_entry_reference.ts' import type { Replace } from './types.ts' import { personsUrl } from './urls.ts' @@ -114,6 +115,17 @@ export type SinglePersonResponse = } & PersonResponse +export type GetPersonRequest = + & PersonReference + & OpportunitiesQueryParams + & InteractionDatesQueryParams + & WithCurrentOrganizatonParams + +export type PersonReference = { + /** The unique ID of the person */ + person_id: number +} + /** * @module * The persons API allows you to manage all the contacts of your organization. @@ -123,6 +135,7 @@ export type SinglePersonResponse = * - If you are looking to add or remove a person from a list, please check out the {@link ListEntries} section of the API. * - If you are looking to modify a person's field values (one of the cells on Affinity's spreadsheet), please check out the {@link FieldValues} section of the API. */ + export class Persons { /** @hidden */ constructor(private readonly axios: AxiosInstance) { @@ -139,6 +152,48 @@ export class Persons { ) } + /** + * Fetches an person with a specified `person_id`. + * + * @returns The person object corresponding to the `person_id`. + * + * @example + * ```typescript + * const person = await affinity.persons.get({ + * person_id: 12345 + * }) + * console.log(person) + * ``` + */ + async get( + params: GetPersonRequest, + ): Promise { + const { person_id, ...rest } = params + const response = await this.axios.get( + personsUrl(person_id), + { + params: rest, + transformResponse: [ + ...defaultTransformers(), + ( + json: SinglePersonResponseRaw, + ): SinglePersonResponse => { + const { list_entries, ...person } = json + return { + ...transformInteractionDateResponseRaw( + person, + ), + list_entries: json.list_entries.map( + transformListEntryReference, + ), + } + }, + ], + }, + ) + return response.data + } + /** * Searches your teams data and fetches all the persons that meet the search criteria. * diff --git a/src/v1/tests/__snapshots__/persons_test.ts.snap b/src/v1/tests/__snapshots__/persons_test.ts.snap index 55aa033..f5c2ce2 100644 --- a/src/v1/tests/__snapshots__/persons_test.ts.snap +++ b/src/v1/tests/__snapshots__/persons_test.ts.snap @@ -1,5 +1,48 @@ export const snapshot = {}; +snapshot[`persons > can get a specific person 1`] = ` +{ + emails: [ + "j@p-a.com", + "j@f.com", + "jf@j.com", + "j@c.com", + "jf@a.com", + ], + first_name: "Joscha", + id: 54576635, + last_name: "Feth", + list_entries: [ + { + created_at: 2023-07-04T14:35:44.910Z, + creator_id: 54530389, + entity_id: 54576635, + entity_type: 0, + id: 133527223, + list_id: 188027, + }, + { + created_at: 2024-05-23T12:27:09.733Z, + creator_id: 54576635, + entity_id: 54576635, + entity_type: 0, + id: 158330666, + list_id: 247888, + }, + ], + organization_ids: [ + 290555008, + 3584837, + 295075617, + 1517097, + 1687113, + 296715824, + ], + primary_email: "j@p-a.com", + type: 1, +} +`; + snapshot[`persons > can search for persons 1`] = ` { next_page_token: null, diff --git a/src/v1/tests/fixtures/persons/get.raw.response.json b/src/v1/tests/fixtures/persons/get.raw.response.json new file mode 100644 index 0000000..dbe7181 --- /dev/null +++ b/src/v1/tests/fixtures/persons/get.raw.response.json @@ -0,0 +1,40 @@ +{ + "emails": [ + "j@p-a.com", + "j@f.com", + "jf@j.com", + "j@c.com", + "jf@a.com" + ], + "first_name": "Joscha", + "id": 54576635, + "last_name": "Feth", + "list_entries": [ + { + "created_at": "2023-07-04T14:35:44.910Z", + "creator_id": 54530389, + "entity_id": 54576635, + "entity_type": 0, + "id": 133527223, + "list_id": 188027 + }, + { + "created_at": "2024-05-23T12:27:09.733Z", + "creator_id": 54576635, + "entity_id": 54576635, + "entity_type": 0, + "id": 158330666, + "list_id": 247888 + } + ], + "organization_ids": [ + 290555008, + 3584837, + 295075617, + 1517097, + 1687113, + 296715824 + ], + "primary_email": "j@p-a.com", + "type": 1 +} diff --git a/src/v1/tests/persons_test.ts b/src/v1/tests/persons_test.ts index 978d5b9..66ab276 100644 --- a/src/v1/tests/persons_test.ts +++ b/src/v1/tests/persons_test.ts @@ -23,15 +23,15 @@ describe('persons', () => { mock?.reset() }) - // it('can get a specific person', async (t) => { - // const person_id = 64779194 - // mock?.onGet(personsUrl(person_id)).reply( - // 200, - // await getRawFixture('persons/get.raw.response.json'), - // ) - // const res = await affinity.persons.get({ person_id }) - // await assertSnapshot(t, res) - // }) + it('can get a specific person', async (t) => { + const person_id = 54576635 + mock?.onGet(personsUrl(person_id)).reply( + 200, + await getRawFixture('persons/get.raw.response.json'), + ) + const res = await affinity.persons.get({ person_id }) + await assertSnapshot(t, res) + }) it('can search for persons', async (t) => { const request = { term: 'joscha' } @@ -76,7 +76,7 @@ describe('persons', () => { // await assertSnapshot(t, res) // }) - // it('can update an person', async (t) => { + // it('can update a person', async (t) => { // const data = { // person_id: 120611418, // name: 'Acme Corp.', @@ -90,7 +90,7 @@ describe('persons', () => { // await assertSnapshot(t, res) // }) - // it('can delete an person', async (t) => { + // it('can delete a person', async (t) => { // const person_id = 120611418 // mock?.onDelete(personsUrl(person_id)).reply(200, { // success: true, diff --git a/src/v1/transform_list_entry_reference.ts b/src/v1/transform_list_entry_reference.ts new file mode 100644 index 0000000..9d3dedf --- /dev/null +++ b/src/v1/transform_list_entry_reference.ts @@ -0,0 +1,12 @@ +import type { ListEntryReferenceRaw } from './list_entries.ts' +import { ListEntryReference } from './organizations.ts' + +export function transformListEntryReference< + T extends ListEntryReferenceRaw, + U extends ListEntryReference, +>(ref: T): U { + return { + ...ref, + created_at: new Date(ref.created_at), + } as U +} From e4688385aeec0154e74a3b488b137f2baac8205e Mon Sep 17 00:00:00 2001 From: Joscha Feth Date: Fri, 26 Jul 2024 00:05:06 +0100 Subject: [PATCH 11/13] ci: add coderabbit config --- .coderabbit.yaml | 3 +++ .yamlfmt | 1 + 2 files changed, 4 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..12e2511 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,3 @@ +reviews: + auto_review: + drafts: true diff --git a/.yamlfmt b/.yamlfmt index e964e27..763a687 100644 --- a/.yamlfmt +++ b/.yamlfmt @@ -1,3 +1,4 @@ gitignore_excludes: true +eof_newline: true exclude: - .yamllint \ No newline at end of file From ce34b20b75076dd38084c2a1cda4a15cf995a229 Mon Sep 17 00:00:00 2001 From: Joscha Feth Date: Fri, 26 Jul 2024 09:16:00 +0100 Subject: [PATCH 12/13] feat: persons endpoint; delete, create, update, fields --- src/v1/organizations.ts | 6 +- src/v1/persons.ts | 203 ++++++++++++++++-- .../tests/__snapshots__/persons_test.ts.snap | 62 ++++++ .../fixtures/persons/create.raw.response.json | 9 + .../persons/get_fields.raw.response.json | 23 ++ .../fixtures/persons/update.raw.response.json | 9 + src/v1/tests/persons_test.ts | 89 ++++---- 7 files changed, 335 insertions(+), 66 deletions(-) create mode 100644 src/v1/tests/fixtures/persons/create.raw.response.json create mode 100644 src/v1/tests/fixtures/persons/get_fields.raw.response.json create mode 100644 src/v1/tests/fixtures/persons/update.raw.response.json diff --git a/src/v1/organizations.ts b/src/v1/organizations.ts index c63942e..1c2abd4 100644 --- a/src/v1/organizations.ts +++ b/src/v1/organizations.ts @@ -283,7 +283,7 @@ export type OrganizationReference = { organization_id: number } -export type OrganizationField = Pick< +export type EntityField = Pick< Field, 'id' | 'name' | 'value_type' | 'allows_multiple' | 'dropdown_options' > @@ -505,9 +505,9 @@ export class Organizations { * console.log(organizationFields) * ``` */ - async getFields(): Promise { + async getFields(): Promise { const response = await this.axios.get< - OrganizationField[] + EntityField[] >( organizationFieldsUrl(), ) diff --git a/src/v1/persons.ts b/src/v1/persons.ts index c615b0d..f9bbf88 100644 --- a/src/v1/persons.ts +++ b/src/v1/persons.ts @@ -3,6 +3,7 @@ import { defaultTransformers } from './axios_default_transformers.ts' import { createSearchIteratorFn } from './create_search_iterator_fn.ts' import type { ListEntryReferenceRaw } from './list_entries.ts' import { + EntityField, InteractionDateResponse, type InteractionDateResponseRaw, InteractionDatesQueryParams, @@ -17,7 +18,7 @@ import type { PagedResponse } from './paged_response.ts' import { transformInteractionDateResponseRaw } from './transform_interaction_date_response_raw.ts' import { transformListEntryReference } from './transform_list_entry_reference.ts' import type { Replace } from './types.ts' -import { personsUrl } from './urls.ts' +import { personFieldsUrl, personsUrl } from './urls.ts' /** * The type of person. @@ -33,6 +34,23 @@ export enum PersonType { INTERNAL = 1, } +export type Person = { + /** The unique identifier of the person object. */ + id: number + /** The type of person. */ + type: PersonType + /** The first name of the person. */ + first_name: string + /** The last name of the person. */ + last_name: string + /** The email addresses of the person. */ + emails: string[] + /** The email (automatically computed) that is most likely to the current active email address of the person. */ + primary_email: string + /** An array of unique identifiers of organizations that the person is associated with. */ + organization_ids: number[] +} + /** * Each person resource is assigned a unique `id` and stores the name, type, and email addresses of the person. A person resource also has access to a smart attribute called `primary_email`. The value of `primary_email` is automatically computed by Affinity's proprietary algorithms and refers to the email that is most likely to be the current active email address of a person. * The person resource `organization_ids` is a collection of unique identifiers to the person's associated organizations. Note that a person can be associated with multiple organizations. For example, say your team has talked with organizations A and B. Person X used to work at A and was your point of contact, but then changed jobs and started emailing you from a new email address (corresponding to organization B). In this case, Affinity will automatically associate person X with both organization A and organization B. @@ -41,21 +59,6 @@ export enum PersonType { */ export type PersonResponseRaw = & { - /** The unique identifier of the person object. */ - id: number - /** The type of person. */ - type: PersonType - /** The first name of the person. */ - first_name: string - /** The last name of the person. */ - last_name: string - /** The email addresses of the person. */ - emails: string[] - /** The email (automatically computed) that is most likely to the current active email address of the person. */ - primary_email: string - /** An array of unique identifiers of organizations that the person is associated with. */ - organization_ids: number[] - /** An array of unique identifiers of organizations that the person is currently associated with according to the Affinity Data: Current Organization in-app column. * Only returned when `{@link WithCurrentOrganizatonParams.with_current_organizations}=true`. * @@ -63,6 +66,7 @@ export type PersonResponseRaw = */ current_organization_ids?: number[] } + & Person & InteractionDateResponseRaw & OpportunityIdResponseRaw @@ -126,6 +130,66 @@ export type PersonReference = { person_id: number } +/** + * The request object for creating an organization. + */ +export type CreatePersonRequest = { + /** + * The first name of the person. + */ + first_name: string + /** + * The last name of the person. + */ + last_name: string + /** + * The email addresses of the person. If there are no email addresses, please specify an empty array. + */ + emails: string[] + /** + * An array of unique identifiers of organizations that the person is associated with. + */ + organization_ids?: number[] +} + +/** + * The request object for updating an organization. + */ +export type UpdatePersonRequest = + & { + /** + * The first name of the person. + */ + first_name?: string + + /** + * The last name of the person. + */ + last_name?: string + + /** + * The email addresses of the person. If there are no email addresses, please specify an empty array. + * + * *Hint*: If you are trying to add a new email to a person, the existing values for `emails` must also be supplied as parameters. + */ + emails?: string[] + + /** + * An array of unique identifiers of organizations that the person is associated with. + * + * *Hint*: If you are trying to add a new organization to a person, the existing values for `organization_ids` must also be supplied as parameters. + */ + organization_ids?: number[] + } + & PersonReference + +export type SimplePersonResponse = + & Person + & Pick< + PersonResponse, + 'organization_ids' + > + /** * @module * The persons API allows you to manage all the contacts of your organization. @@ -207,10 +271,10 @@ export class Persons { * * @example * ```typescript - * const result = await affinity.persons.search({ - * term: 'ben' + * const { persons: allAlices } = await affinity.persons.search({ + * term: 'Alice' * }) - * console.log(result.primary_email) + * console.log(allAlices) * ``` */ async search( @@ -251,12 +315,109 @@ export class Persons { * ```typescript * let page = 0 * for await (const entries of affinity.persons.searchIterator({ - * term: 'ben', + * term: 'Alice', * page_size: 10 * })) { * console.log(`Page ${++page} of entries:`, entries) * } * ``` */ - searchIterator = createSearchIteratorFn(this.search.bind(this), 'persons') + searchIterator = createSearchIteratorFn( + this.search.bind(this), + 'persons', + ) + + /** + * Creates a new person with the supplied parameters. + * + * @param data - Object containing the data for creating a new person + * @returns The person resource that was just created. + * + * @example + * ```typescript + * const newPerson = await affinity.persons.create({ + * first_name: 'Alice', + * last_name: 'Doe', + * emails: ['alice@doe.com'], + * organization_ids: [123456] + * }) + * console.log(newPerson) + * ``` + */ + async create( + data: CreatePersonRequest, + ): Promise { + const response = await this.axios.post( + personsUrl(), + data, + ) + return response.data + } + + /** + * Updates an existing person with `person_id` with the supplied parameters. + * + * @param data - Object containing the data for updating an person + * @returns The person resource that was just updated. + * + * @example + * ```typescript + * const updatedPerson = await affinity.persons.update({ + * person_id: 12345, + * name: 'Acme Corp.', + * person_ids: [38706, 89734] + * }) + * console.log(updatedPerson) + * ``` + */ + async update( + data: UpdatePersonRequest, + ): Promise { + const { person_id, ...rest } = data + const response = await this.axios.put( + personsUrl(person_id), + rest, + ) + return response.data + } + + /** + * Deletes an person with a specified `person_id`. + * @returns true if the deletion was successful + * + * @example + * ```typescript + * const success = await affinity.persons.delete({ + * person_id: 12345 + * }) + * console.log(success ? 'Person deleted': 'Person not deleted') + * ``` + */ + async delete(request: PersonReference): Promise { + const { person_id } = request + const response = await this.axios.delete<{ success: boolean }>( + personsUrl(person_id), + ) + return response.data.success === true + } + + /** + * Fetches an array of all the global fields that exist on persons. + * + * @returns An array of the fields that exist on all persons for your team. + * + * @example + * ```typescript + * const personFields = await affinity.persons.getFields() + * console.log(personFields) + * ``` + */ + async getFields(): Promise { + const response = await this.axios.get< + EntityField[] + >( + personFieldsUrl(), + ) + return response.data + } } diff --git a/src/v1/tests/__snapshots__/persons_test.ts.snap b/src/v1/tests/__snapshots__/persons_test.ts.snap index f5c2ce2..5bfe43c 100644 --- a/src/v1/tests/__snapshots__/persons_test.ts.snap +++ b/src/v1/tests/__snapshots__/persons_test.ts.snap @@ -85,6 +85,68 @@ snapshot[`persons > can search for persons 1`] = ` } `; +snapshot[`persons > can create a new person 1`] = ` +{ + emails: [ + "alice@affinity.co", + ], + first_name: "Alice", + id: 860197, + last_name: "Doe", + organization_ids: [ + 1687449, + ], + primary_email: "alice@affinity.co", + type: 0, +} +`; + +snapshot[`persons > can update a person 1`] = ` +{ + emails: [ + "alice@affinity.co", + "allison@example.com", + "allison@gmail.com", + ], + first_name: "Allison", + id: 860197, + last_name: "Doe", + organization_ids: [ + 1687449, + ], + primary_email: "alice@affinity.co", + type: 0, +} +`; + +snapshot[`persons > can delete a person 1`] = `true`; + +snapshot[`persons > can get global person fields 1`] = ` +[ + { + allows_multiple: true, + dropdown_options: [], + id: 125, + name: "Use Case", + value_type: 2, + }, + { + allows_multiple: true, + dropdown_options: [], + id: 198, + name: "Referrers", + value_type: 0, + }, + { + allows_multiple: false, + dropdown_options: [], + id: 1615, + name: "Address", + value_type: 5, + }, +] +`; + snapshot[`page 1 of persons 1`] = ` [ { diff --git a/src/v1/tests/fixtures/persons/create.raw.response.json b/src/v1/tests/fixtures/persons/create.raw.response.json new file mode 100644 index 0000000..b99b4e1 --- /dev/null +++ b/src/v1/tests/fixtures/persons/create.raw.response.json @@ -0,0 +1,9 @@ +{ + "id": 860197, + "type": 0, + "first_name": "Alice", + "last_name": "Doe", + "primary_email": "alice@affinity.co", + "emails": ["alice@affinity.co"], + "organization_ids": [1687449] +} diff --git a/src/v1/tests/fixtures/persons/get_fields.raw.response.json b/src/v1/tests/fixtures/persons/get_fields.raw.response.json new file mode 100644 index 0000000..ffcf806 --- /dev/null +++ b/src/v1/tests/fixtures/persons/get_fields.raw.response.json @@ -0,0 +1,23 @@ +[ + { + "id": 125, + "name": "Use Case", + "value_type": 2, + "allows_multiple": true, + "dropdown_options": [] + }, + { + "id": 198, + "name": "Referrers", + "value_type": 0, + "allows_multiple": true, + "dropdown_options": [] + }, + { + "id": 1615, + "name": "Address", + "value_type": 5, + "allows_multiple": false, + "dropdown_options": [] + } +] diff --git a/src/v1/tests/fixtures/persons/update.raw.response.json b/src/v1/tests/fixtures/persons/update.raw.response.json new file mode 100644 index 0000000..5e761a3 --- /dev/null +++ b/src/v1/tests/fixtures/persons/update.raw.response.json @@ -0,0 +1,9 @@ +{ + "id": 860197, + "type": 0, + "first_name": "Allison", + "last_name": "Doe", + "primary_email": "alice@affinity.co", + "emails": ["alice@affinity.co", "allison@example.com", "allison@gmail.com"], + "organization_ids": [1687449] +} diff --git a/src/v1/tests/persons_test.ts b/src/v1/tests/persons_test.ts index 66ab276..aa911e5 100644 --- a/src/v1/tests/persons_test.ts +++ b/src/v1/tests/persons_test.ts @@ -62,51 +62,56 @@ describe('persons', () => { // await assertSnapshot(t, res) // }) - // it('can create a new person', async (t) => { - // const data = { - // name: 'Acme Corporation', - // domain: 'acme.co', - // person_ids: [38706], - // } - // mock?.onPost(personsUrl()).reply( - // 201, - // await getRawFixture('persons/create.raw.response.json'), - // ) - // const res = await affinity.persons.create(data) - // await assertSnapshot(t, res) - // }) + it('can create a new person', async (t) => { + const data = { + first_name: 'Alice', + last_name: 'Doe', + emails: ['alice@affinity.co'], + organization_ids: [1687449], + } + mock?.onPost(personsUrl()).reply( + 201, + await getRawFixture('persons/create.raw.response.json'), + ) + const res = await affinity.persons.create(data) + await assertSnapshot(t, res) + }) - // it('can update a person', async (t) => { - // const data = { - // person_id: 120611418, - // name: 'Acme Corp.', - // person_ids: [38706, 89734], - // } - // mock?.onPut(personsUrl(data.person_id)).reply( - // 200, - // await getRawFixture('persons/update.raw.response.json'), - // ) - // const res = await affinity.persons.update(data) - // await assertSnapshot(t, res) - // }) + it('can update a person', async (t) => { + const data = { + person_id: 860197, + first_name: 'Allison', + emails: [ + 'alice@affinity.co', + 'allison@example.com', + 'allison@gmail.com', + ], + } + mock?.onPut(personsUrl(data.person_id)).reply( + 200, + await getRawFixture('persons/update.raw.response.json'), + ) + const res = await affinity.persons.update(data) + await assertSnapshot(t, res) + }) - // it('can delete a person', async (t) => { - // const person_id = 120611418 - // mock?.onDelete(personsUrl(person_id)).reply(200, { - // success: true, - // }) - // const res = await affinity.persons.delete({ person_id }) - // await assertSnapshot(t, res) - // }) + it('can delete a person', async (t) => { + const person_id = 860197 + mock?.onDelete(personsUrl(person_id)).reply(200, { + success: true, + }) + const res = await affinity.persons.delete({ person_id }) + await assertSnapshot(t, res) + }) - // it('can get global person fields', async (t) => { - // mock?.onGet(personFieldsUrl()).reply( - // 200, - // await getRawFixture('persons/get_fields.raw.response.json'), - // ) - // const res = await affinity.persons.getFields() - // await assertSnapshot(t, res) - // }) + it('can get global person fields', async (t) => { + mock?.onGet(personFieldsUrl()).reply( + 200, + await getRawFixture('persons/get_fields.raw.response.json'), + ) + const res = await affinity.persons.getFields() + await assertSnapshot(t, res) + }) it('iterates over all persons', async (t) => { const params: SearchPersonsRequest = { From 00475a5ad6010bcfa884ae1ce66c06e8534dc0f9 Mon Sep 17 00:00:00 2001 From: Joscha Feth Date: Fri, 26 Jul 2024 09:40:01 +0100 Subject: [PATCH 13/13] fix: persons ednpoint; dates search --- .../tests/__snapshots__/persons_test.ts.snap | 45 +++++++++++++++++++ .../search_with_dates.raw.response.json | 30 +++++++++++++ src/v1/tests/organizations_test.ts | 2 + src/v1/tests/persons_test.ts | 38 ++++++++-------- ...transform_interaction_date_response_raw.ts | 18 +++++--- 5 files changed, 110 insertions(+), 23 deletions(-) create mode 100644 src/v1/tests/fixtures/persons/search_with_dates.raw.response.json diff --git a/src/v1/tests/__snapshots__/persons_test.ts.snap b/src/v1/tests/__snapshots__/persons_test.ts.snap index 5bfe43c..9976da8 100644 --- a/src/v1/tests/__snapshots__/persons_test.ts.snap +++ b/src/v1/tests/__snapshots__/persons_test.ts.snap @@ -85,6 +85,51 @@ snapshot[`persons > can search for persons 1`] = ` } `; +snapshot[`persons > can search for persons with the appropriate dates 1`] = ` +{ + persons: [ + { + emails: [ + "j@f.com", + ], + first_name: "Joscha", + id: 12345678, + interaction_dates: { + first_email_date: 2021-05-31T16:00:37.000Z, + first_event_date: 2021-05-20T06:30:00.000Z, + last_chat_message_date: null, + last_email_date: 2022-05-12T08:01:31.000Z, + last_event_date: 2022-07-08T07:00:00.000Z, + last_interaction_date: 2022-07-08T07:00:00.000Z, + next_event_date: null, + }, + interactions: { + first_email: { + date: 2021-05-31T16:00:37.000Z, + }, + first_event: { + date: 2021-05-20T06:30:00.000Z, + }, + last_chat_message: null, + last_email: { + date: 2022-05-12T08:01:31.000Z, + }, + last_event: { + date: 2022-07-08T07:00:00.000Z, + }, + last_interaction: { + date: 2022-07-08T07:00:00.000Z, + }, + next_event: null, + }, + last_name: "Feth", + primary_email: "j@f.com", + type: 0, + }, + ], +} +`; + snapshot[`persons > can create a new person 1`] = ` { emails: [ diff --git a/src/v1/tests/fixtures/persons/search_with_dates.raw.response.json b/src/v1/tests/fixtures/persons/search_with_dates.raw.response.json new file mode 100644 index 0000000..1834110 --- /dev/null +++ b/src/v1/tests/fixtures/persons/search_with_dates.raw.response.json @@ -0,0 +1,30 @@ +{ + "persons": [ + { + "id": 12345678, + "type": 0, + "first_name": "Joscha", + "last_name": "Feth", + "primary_email": "j@f.com", + "emails": ["j@f.com"], + "interaction_dates": { + "first_email_date": "2021-05-31T09:00:37.000-07:00", + "first_event_date": "2021-05-19T23:30:00.000-07:00", + "last_email_date": "2022-05-12T01:01:31.000-07:00", + "last_event_date": "2022-07-08T00:00:00.000-07:00", + "last_chat_message_date": null, + "last_interaction_date": "2022-07-08T00:00:00.000-07:00", + "next_event_date": null + }, + "interactions": { + "first_email": { "date": "2021-05-31T09:00:37.000-07:00" }, + "last_email": { "date": "2022-05-12T01:01:31.000-07:00" }, + "first_event": { "date": "2021-05-19T23:30:00.000-07:00" }, + "next_event": null, + "last_event": { "date": "2022-07-08T00:00:00.000-07:00" }, + "last_chat_message": null, + "last_interaction": { "date": "2022-07-08T00:00:00.000-07:00" } + } + } + ] +} diff --git a/src/v1/tests/organizations_test.ts b/src/v1/tests/organizations_test.ts index 3e45164..33b5754 100644 --- a/src/v1/tests/organizations_test.ts +++ b/src/v1/tests/organizations_test.ts @@ -48,11 +48,13 @@ describe('organizations', () => { const request: SearchOrganizationsRequest = { min_first_email_date: myDate, term: 'affinity', + with_interaction_dates: true, } mock?.onGet(organizationsUrl(), { params: { term: request.term, min_first_email_date: myDate.toISOString(), + with_interaction_dates: true, }, }).reply( 200, diff --git a/src/v1/tests/persons_test.ts b/src/v1/tests/persons_test.ts index aa911e5..a6d6756 100644 --- a/src/v1/tests/persons_test.ts +++ b/src/v1/tests/persons_test.ts @@ -43,24 +43,26 @@ describe('persons', () => { await assertSnapshot(t, res) }) - // it('can search for persons with the appropriate dates', async (t) => { - // const myDate = new Date(1717428411010) - // const request: SearchPersonsRequest = { - // min_first_email_date: myDate, - // term: 'joscha', - // } - // mock?.onGet(personsUrl(), { - // params: { - // term: request.term, - // min_first_email_date: myDate.toISOString(), - // }, - // }).reply( - // 200, - // await getRawFixture('persons/search.raw.response.json'), - // ) - // const res = await affinity.persons.search(request) - // await assertSnapshot(t, res) - // }) + it('can search for persons with the appropriate dates', async (t) => { + const myDate = new Date(0) + const request: SearchPersonsRequest = { + min_first_email_date: myDate, + term: 'Joscha', + with_interaction_dates: true, + } + mock?.onGet(personsUrl(), { + params: { + term: request.term, + min_first_email_date: myDate.toISOString(), + with_interaction_dates: true, + }, + }).reply( + 200, + await getRawFixture('persons/search_with_dates.raw.response.json'), + ) + const res = await affinity.persons.search(request) + await assertSnapshot(t, res) + }) it('can create a new person', async (t) => { const data = { diff --git a/src/v1/transform_interaction_date_response_raw.ts b/src/v1/transform_interaction_date_response_raw.ts index cf0ea62..18928ae 100644 --- a/src/v1/transform_interaction_date_response_raw.ts +++ b/src/v1/transform_interaction_date_response_raw.ts @@ -22,17 +22,25 @@ export function transformInteractionDateResponseRaw< if (interaction_dates) { dates.interaction_dates = Object.fromEntries( Object.entries(interaction_dates).map( - ([key, value]) => [key, new Date(value)], + ([key, value]) => [ + key, + value ? new Date(value) : null, + ], ), ) as Record } if (interactions) { dates.interactions = Object.fromEntries( Object.entries(interactions).map( - ([key, value]) => [key, { - ...value, - date: new Date(value.date), - }], + ([key, value]) => [ + key, + value + ? { + ...value, + date: new Date(value.date), + } + : null, + ], ), ) as Record }