diff --git a/packages/twenty-apps/public/twenty-last-contact/CHANGELOG.md b/packages/twenty-apps/public/twenty-last-contact/CHANGELOG.md new file mode 100644 index 0000000000..8c4df79ac4 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 1.1.0 + +- Add last contact on Companies and Opportunities. +- Set last-contact fields readonly. + +## 1.0.0 + +- Initial release: "Last contact by" and "Last contact item" tracking on People, powered by calendar and message sync. diff --git a/packages/twenty-apps/public/twenty-last-contact/README.md b/packages/twenty-apps/public/twenty-last-contact/README.md index 54904b547a..9e02add896 100644 --- a/packages/twenty-apps/public/twenty-last-contact/README.md +++ b/packages/twenty-apps/public/twenty-last-contact/README.md @@ -4,17 +4,20 @@ ## ✨ What you get -- **Seven live columns** — last contact, who reached out (you or them), the owning teammate, and the exact email or meeting behind it +- **Live columns on People, Companies and Opportunities** — last contact, who reached out (you or them), the owning teammate, and the exact email or meeting behind it - **Zero upkeep** — updates in real time from every synced email and meeting, with your full history backfilled the moment you install - **Follow-ups made obvious** — sort by recency to catch cold relationships and see who owes whom a reply ## 📊 The columns +On **People** you get: - **Last contact** — the most recent touch, either direction - **Last outbound** / **Last inbound** — when you last reached out vs. when they last did - **Last contact by** — the teammate connected to this person - **Last contact item** / **Last email** / **Last meeting** — one click to the actual record +On **Companies** and **Opportunities** you also get **Last contact** and **Last contact item** columns. + ## 💳 Billing **Free** — no credits, no metering. diff --git a/packages/twenty-apps/public/twenty-last-contact/package.json b/packages/twenty-apps/public/twenty-last-contact/package.json index 51cdfb4764..58b5c98d68 100644 --- a/packages/twenty-apps/public/twenty-last-contact/package.json +++ b/packages/twenty-apps/public/twenty-last-contact/package.json @@ -1,6 +1,6 @@ { "name": "@twentyhq/last-contact", - "version": "1.0.3", + "version": "1.1.0", "license": "MIT", "engines": { "node": "^24.5.0", diff --git a/packages/twenty-apps/public/twenty-last-contact/src/__tests__/last-contact.integration-test.ts b/packages/twenty-apps/public/twenty-last-contact/src/__tests__/last-contact.integration-test.ts index 8d5fa3464b..f55048027e 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/__tests__/last-contact.integration-test.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/__tests__/last-contact.integration-test.ts @@ -38,6 +38,87 @@ const createPerson = async (client: CoreApiClient): Promise => { return requireId(result.createPerson?.id, 'createPerson'); }; +const createCompany = async (client: CoreApiClient): Promise => { + const result = await client.mutation({ + createCompany: { + __args: { data: { name: `[test-last-contact] company ${Date.now()}` } }, + id: true, + }, + }); + + return requireId(result.createCompany?.id, 'createCompany'); +}; + +const createOpportunity = async ( + client: CoreApiClient, + { + pointOfContactId, + companyId, + }: { pointOfContactId?: string; companyId?: string }, +): Promise => { + const result = await client.mutation({ + createOpportunity: { + __args: { + data: { + name: `[test-last-contact] opportunity ${Date.now()}`, + ...(pointOfContactId ? { pointOfContactId } : {}), + ...(companyId ? { companyId } : {}), + }, + }, + id: true, + }, + }); + + return requireId(result.createOpportunity?.id, 'createOpportunity'); +}; + +const setPersonCompany = async ( + client: CoreApiClient, + { personId, companyId }: { personId: string; companyId: string }, +): Promise => { + await client.mutation({ + updatePerson: { + __args: { id: personId, data: { companyId } }, + id: true, + }, + }); +}; + +type RelatedLastContact = { + lastContactAt: string | null; + lastContactItemMessageId: string | null; + lastContactItemCalendarEventId: string | null; +}; + +const getRelatedLastContact = async ( + client: CoreApiClient, + objectNameSingular: 'company' | 'opportunity', + recordId: string, +): Promise => { + const result = await client.query({ + [objectNameSingular]: { + __args: { filter: { id: { eq: recordId } } }, + id: true, + lastContactAt: true, + lastContactItemMessage: { id: true }, + lastContactItemCalendarEvent: { id: true }, + }, + }); + + const record = result[objectNameSingular] as { + lastContactAt?: string | null; + lastContactItemMessage?: { id: string } | null; + lastContactItemCalendarEvent?: { id: string } | null; + } | null; + + return { + lastContactAt: record?.lastContactAt ?? null, + lastContactItemMessageId: record?.lastContactItemMessage?.id ?? null, + lastContactItemCalendarEventId: + record?.lastContactItemCalendarEvent?.id ?? null, + }; +}; + const createCalendarEvent = async ( client: CoreApiClient, { startsAt, isCanceled = false }: { startsAt: string; isCanceled?: boolean }, @@ -329,6 +410,8 @@ describe('last contact handlers', () => { const createdMessageAssociationIds: string[] = []; const createdMessageIds: string[] = []; const createdPersonIds: string[] = []; + const createdOpportunityIds: string[] = []; + const createdCompanyIds: string[] = []; const createLinkedMessage = async ( receivedAt: string, @@ -476,12 +559,26 @@ describe('last contact handlers', () => { } createdMessageIds.length = 0; + for (const id of createdOpportunityIds) { + await client + .mutation({ destroyOpportunity: { __args: { id }, id: true } }) + .catch(() => {}); + } + createdOpportunityIds.length = 0; + for (const id of createdPersonIds) { await client .mutation({ destroyPerson: { __args: { id }, id: true } }) .catch(() => {}); } createdPersonIds.length = 0; + + for (const id of createdCompanyIds) { + await client + .mutation({ destroyCompany: { __args: { id }, id: true } }) + .catch(() => {}); + } + createdCompanyIds.length = 0; }); it('should expose a lastContactAt field on people, unset by default', async () => { @@ -764,6 +861,109 @@ describe('last contact handlers', () => { }); }); + it("sets the company and opportunity last contact from a related person's email", async () => { + const workspaceMemberId = await getWorkspaceMemberId(client); + const companyId = await createCompany(client); + createdCompanyIds.push(companyId); + const personId = await createPerson(client); + createdPersonIds.push(personId); + await setPersonCompany(client, { personId, companyId }); + const opportunityId = await createOpportunity(client, { + pointOfContactId: personId, + companyId, + }); + createdOpportunityIds.push(opportunityId); + const receivedAt = new Date(Date.now() - 2 * DAY_IN_MS).toISOString(); + + const messageId = await recordEmail({ + personId, + workspaceMemberId, + receivedAt, + direction: 'outbound', + }); + + const companyContact = await getRelatedLastContact( + client, + 'company', + companyId, + ); + expect(asTime(companyContact.lastContactAt)).toBe(asTime(receivedAt)); + expect(companyContact.lastContactItemMessageId).toBe(messageId); + expect(companyContact.lastContactItemCalendarEventId).toBeNull(); + + const opportunityContact = await getRelatedLastContact( + client, + 'opportunity', + opportunityId, + ); + expect(asTime(opportunityContact.lastContactAt)).toBe(asTime(receivedAt)); + expect(opportunityContact.lastContactItemMessageId).toBe(messageId); + }); + + it("lets a later meeting supersede an email on the company's last contact", async () => { + const workspaceMemberId = await getWorkspaceMemberId(client); + const companyId = await createCompany(client); + createdCompanyIds.push(companyId); + const personId = await createPerson(client); + createdPersonIds.push(personId); + await setPersonCompany(client, { personId, companyId }); + const emailAt = new Date(Date.now() - 3 * DAY_IN_MS).toISOString(); + const meetingAt = new Date(Date.now() - 2 * DAY_IN_MS).toISOString(); + + await recordEmail({ + personId, + workspaceMemberId, + receivedAt: emailAt, + direction: 'outbound', + }); + const calendarEventId = await recordMeeting({ + personId, + workspaceMemberId, + startsAt: meetingAt, + }); + + const companyContact = await getRelatedLastContact( + client, + 'company', + companyId, + ); + expect(asTime(companyContact.lastContactAt)).toBe(asTime(meetingAt)); + expect(companyContact.lastContactItemCalendarEventId).toBe(calendarEventId); + expect(companyContact.lastContactItemMessageId).toBeNull(); + }); + + it('does not overwrite a company last contact with an older interaction', async () => { + const workspaceMemberId = await getWorkspaceMemberId(client); + const companyId = await createCompany(client); + createdCompanyIds.push(companyId); + const personId = await createPerson(client); + createdPersonIds.push(personId); + await setPersonCompany(client, { personId, companyId }); + const newerAt = new Date(Date.now() - DAY_IN_MS).toISOString(); + const olderAt = new Date(Date.now() - 3 * DAY_IN_MS).toISOString(); + + const newerMessageId = await recordEmail({ + personId, + workspaceMemberId, + receivedAt: newerAt, + direction: 'outbound', + }); + await recordEmail({ + personId, + workspaceMemberId, + receivedAt: olderAt, + direction: 'inbound', + }); + + const companyContact = await getRelatedLastContact( + client, + 'company', + companyId, + ); + expect(asTime(companyContact.lastContactAt)).toBe(asTime(newerAt)); + expect(companyContact.lastContactItemMessageId).toBe(newerMessageId); + }); + it('lets a later meeting supersede an earlier outbound email', async () => { const workspaceMemberId = await getWorkspaceMemberId(client); const personId = await createPerson(client); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/constants/universal-identifiers.ts b/packages/twenty-apps/public/twenty-last-contact/src/constants/universal-identifiers.ts index dd967fbd27..b688911411 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/constants/universal-identifiers.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/constants/universal-identifiers.ts @@ -1,6 +1,6 @@ export const APP_DISPLAY_NAME = 'Last contact'; export const APP_DESCRIPTION = - 'Know where every relationship stands. Adds Last contact, Last outbound, Last inbound, Last contact by, and the last email and meeting to People, kept up to date automatically from your synced emails and meetings.'; + 'Know where every relationship stands. Adds Last contact, Last outbound, Last inbound, Last contact by, and the last email and meeting to People, plus Last contact on Companies and Opportunities, kept up to date automatically from your synced emails and meetings.'; export const APPLICATION_UNIVERSAL_IDENTIFIER = '66a504cc-0a75-410e-a43f-cdeae1db1522'; export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = '34187abe-1b98-4153-85cd-4808e0aebf30'; export const LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER = @@ -50,3 +50,41 @@ export const LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER = 'c8882287-f638-4a96-a235-1819e793e373'; export const LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER = '257dd874-d834-403d-9cb8-f3db7e587d02'; + +export const COMPANY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER = + '4d84b45d-128d-460f-858a-f877bf6e58ac'; +export const COMPANY_LAST_CONTACT_ITEM_MORPH_ID = + '64a265e9-c597-4ca4-a5f2-570d9661eea7'; +export const COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER = + 'f43de098-74a9-491c-b718-2e132332c722'; +export const COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER = + '4ef5546f-97e3-4eff-8090-91b5c6400ac9'; +export const LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER = + '9f08c2e0-23ba-413c-94c8-04ba828586a3'; +export const LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER = + '79103905-cfdb-413a-b964-8f347332ef3c'; +export const COMPANY_LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER = + '08244258-dc60-475f-a2c7-3122f8b658fb'; +export const COMPANY_LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER = + '7adc5b23-1a39-4bb6-8cff-ac4e979d38f3'; +export const COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER = + '61a3b683-3129-4265-889e-309d7ec57403'; + +export const OPPORTUNITY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER = + '69225d61-0efb-4959-af6d-566caba58412'; +export const OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID = + '9199fef1-06e3-4024-8a4a-b4eee554418e'; +export const OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER = + '0b34dd29-35e7-4082-bfd1-551513132ba1'; +export const OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER = + '1f1645d4-d141-413b-a07d-c428240eed2f'; +export const LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER = + '1ccff5c2-44c3-4a7d-847f-c74440fe56a9'; +export const LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER = + 'db36a44e-a6dc-4bec-a23e-3b27f63d1629'; +export const OPPORTUNITY_LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER = + '8b447030-5124-4cfa-bd96-9836c25d4fc9'; +export const OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER = + 'cac2ec01-6c8b-4d50-b897-7717b2679842'; +export const OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER = + 'ab720bee-a0f3-41b9-9c94-8beb50e0b525'; diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/company-last-contact-at.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/company-last-contact-at.field.ts new file mode 100644 index 0000000000..7c89bf3233 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/company-last-contact-at.field.ts @@ -0,0 +1,21 @@ +import { + defineField, + FieldType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { COMPANY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: COMPANY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + name: 'lastContactAt', + type: FieldType.DATE_TIME, + label: 'Last contact', + description: + 'When the most recent contact (email or meeting) with a person from this company occurred, in either direction.', + icon: 'IconClock', + isNullable: true, + isUIEditable: false, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/company-last-contact-item-calendar-event.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/company-last-contact-item-calendar-event.field.ts new file mode 100644 index 0000000000..7346c619e3 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/company-last-contact-item-calendar-event.field.ts @@ -0,0 +1,38 @@ +import { + defineField, + FieldType, + OnDeleteAction, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + COMPANY_LAST_CONTACT_ITEM_MORPH_ID, + LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: + COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + type: FieldType.MORPH_RELATION, + name: 'lastContactItemCalendarEvent', + label: 'Last contact item', + description: + 'The email or meeting that was the most recent contact with a person from this company.', + icon: 'IconCalendarEvent', + isNullable: true, + morphId: COMPANY_LAST_CONTACT_ITEM_MORPH_ID, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'lastContactItemCalendarEventId', + }, + isUIEditable: false, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/company-last-contact-item-message.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/company-last-contact-item-message.field.ts new file mode 100644 index 0000000000..9cf4aad643 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/company-last-contact-item-message.field.ts @@ -0,0 +1,38 @@ +import { + defineField, + FieldType, + OnDeleteAction, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + COMPANY_LAST_CONTACT_ITEM_MORPH_ID, + LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: + COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + type: FieldType.MORPH_RELATION, + name: 'lastContactItemMessage', + label: 'Last contact item', + description: + 'The email or meeting that was the most recent contact with a person from this company.', + icon: 'IconMessage', + isNullable: true, + morphId: COMPANY_LAST_CONTACT_ITEM_MORPH_ID, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'lastContactItemMessageId', + }, + isUIEditable: false, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-at.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-at.field.ts index 40cfeab75f..7ea7c28732 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-at.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-at.field.ts @@ -17,4 +17,5 @@ export default defineField({ 'When the most recent contact (email or meeting) with this person occurred, in either direction.', icon: 'IconClock', isNullable: true, + isUIEditable: false, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-by.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-by.field.ts index 833cc0b1d7..f08aa0ad36 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-by.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-by.field.ts @@ -31,4 +31,5 @@ export default defineField({ onDelete: OnDeleteAction.SET_NULL, joinColumnName: 'lastContactById', }, + isUIEditable: false, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-companies-on-calendar-event.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-companies-on-calendar-event.field.ts new file mode 100644 index 0000000000..5f39b565f6 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-companies-on-calendar-event.field.ts @@ -0,0 +1,32 @@ +import { + defineField, + FieldType, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: + LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, + type: FieldType.RELATION, + name: 'lastContactForCompanies', + label: 'Last contact for companies', + description: 'Companies whose most recent contact was this meeting.', + icon: 'IconBuildingSkyscraper', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, + isUIEditable: false, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-companies-on-message.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-companies-on-message.field.ts new file mode 100644 index 0000000000..59a4e6ac7c --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-companies-on-message.field.ts @@ -0,0 +1,32 @@ +import { + defineField, + FieldType, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: + LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier, + type: FieldType.RELATION, + name: 'lastContactForCompanies', + label: 'Last contact for companies', + description: 'Companies whose most recent contact was this email.', + icon: 'IconBuildingSkyscraper', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, + isUIEditable: false, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-opportunities-on-calendar-event.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-opportunities-on-calendar-event.field.ts new file mode 100644 index 0000000000..4d45a49479 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-opportunities-on-calendar-event.field.ts @@ -0,0 +1,32 @@ +import { + defineField, + FieldType, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: + LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, + type: FieldType.RELATION, + name: 'lastContactForOpportunities', + label: 'Last contact for opportunities', + description: 'Opportunities whose most recent contact was this meeting.', + icon: 'IconTargetArrow', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, + isUIEditable: false, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-opportunities-on-message.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-opportunities-on-message.field.ts new file mode 100644 index 0000000000..efd1d22ccf --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-opportunities-on-message.field.ts @@ -0,0 +1,32 @@ +import { + defineField, + FieldType, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: + LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier, + type: FieldType.RELATION, + name: 'lastContactForOpportunities', + label: 'Last contact for opportunities', + description: 'Opportunities whose most recent contact was this email.', + icon: 'IconTargetArrow', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, + isUIEditable: false, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-calendar-event.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-calendar-event.field.ts index e97d6bf22f..2669bd60d6 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-calendar-event.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-calendar-event.field.ts @@ -28,4 +28,5 @@ export default defineField({ universalSettings: { relationType: RelationType.ONE_TO_MANY, }, + isUIEditable: false, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-message.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-message.field.ts index 720ff2fb86..345f1dfb00 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-message.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-message.field.ts @@ -28,4 +28,5 @@ export default defineField({ universalSettings: { relationType: RelationType.ONE_TO_MANY, }, + isUIEditable: false, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-workspace-member.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-workspace-member.field.ts index d810dcee24..06e56b80b6 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-workspace-member.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-workspace-member.field.ts @@ -28,4 +28,5 @@ export default defineField({ universalSettings: { relationType: RelationType.ONE_TO_MANY, }, + isUIEditable: false, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-calendar-event.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-calendar-event.field.ts index 51c4444f2d..7674db71ec 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-calendar-event.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-calendar-event.field.ts @@ -33,4 +33,5 @@ export default defineField({ onDelete: OnDeleteAction.SET_NULL, joinColumnName: 'lastContactItemCalendarEventId', }, + isUIEditable: false, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-message.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-message.field.ts index 10e4452336..384fd024cc 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-message.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-message.field.ts @@ -32,4 +32,5 @@ export default defineField({ onDelete: OnDeleteAction.SET_NULL, joinColumnName: 'lastContactItemMessageId', }, + isUIEditable: false, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email-for-people-on-message.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email-for-people-on-message.field.ts index 7f29635262..e1aa9d8f63 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email-for-people-on-message.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email-for-people-on-message.field.ts @@ -27,4 +27,5 @@ export default defineField({ universalSettings: { relationType: RelationType.ONE_TO_MANY, }, + isUIEditable: false, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email.field.ts index 026c807b04..6c8af82e1f 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email.field.ts @@ -30,4 +30,5 @@ export default defineField({ onDelete: OnDeleteAction.SET_NULL, joinColumnName: 'lastEmailId', }, + isUIEditable: false, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-inbound-at.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-inbound-at.field.ts index 17f0837e15..2b7ef0bfcd 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-inbound-at.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-inbound-at.field.ts @@ -17,4 +17,5 @@ export default defineField({ 'When this person last reached out to you (an inbound email, or a meeting they organized).', icon: 'IconMessageDown', isNullable: true, + isUIEditable: false, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting-for-people-on-calendar-event.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting-for-people-on-calendar-event.field.ts index ecf34229e1..d829c2251f 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting-for-people-on-calendar-event.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting-for-people-on-calendar-event.field.ts @@ -27,4 +27,5 @@ export default defineField({ universalSettings: { relationType: RelationType.ONE_TO_MANY, }, + isUIEditable: false, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting.field.ts index b397cdc8d8..36d5efffb7 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting.field.ts @@ -30,4 +30,5 @@ export default defineField({ onDelete: OnDeleteAction.SET_NULL, joinColumnName: 'lastMeetingId', }, + isUIEditable: false, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-outbound-at.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-outbound-at.field.ts index 7c4d9ad611..f7378bcd77 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-outbound-at.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-outbound-at.field.ts @@ -17,4 +17,5 @@ export default defineField({ 'When your team last reached out to this person (an outbound email, or a meeting your team organized).', icon: 'IconMessageUp', isNullable: true, + isUIEditable: false, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/opportunity-last-contact-at.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/opportunity-last-contact-at.field.ts new file mode 100644 index 0000000000..6e0fde060f --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/opportunity-last-contact-at.field.ts @@ -0,0 +1,21 @@ +import { + defineField, + FieldType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { OPPORTUNITY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: OPPORTUNITY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + name: 'lastContactAt', + type: FieldType.DATE_TIME, + label: 'Last contact', + description: + 'When the most recent contact (email or meeting) with a person related to this opportunity occurred, in either direction.', + icon: 'IconClock', + isNullable: true, + isUIEditable: false, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/opportunity-last-contact-item-calendar-event.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/opportunity-last-contact-item-calendar-event.field.ts new file mode 100644 index 0000000000..60e8280f9d --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/opportunity-last-contact-item-calendar-event.field.ts @@ -0,0 +1,38 @@ +import { + defineField, + FieldType, + OnDeleteAction, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: + OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + type: FieldType.MORPH_RELATION, + name: 'lastContactItemCalendarEvent', + label: 'Last contact item', + description: + 'The email or meeting that was the most recent contact with a person related to this opportunity.', + icon: 'IconCalendarEvent', + isNullable: true, + morphId: OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'lastContactItemCalendarEventId', + }, + isUIEditable: false, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/opportunity-last-contact-item-message.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/opportunity-last-contact-item-message.field.ts new file mode 100644 index 0000000000..2d30e469e3 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/opportunity-last-contact-item-message.field.ts @@ -0,0 +1,38 @@ +import { + defineField, + FieldType, + OnDeleteAction, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: + OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + type: FieldType.MORPH_RELATION, + name: 'lastContactItemMessage', + label: 'Last contact item', + description: + 'The email or meeting that was the most recent contact with a person related to this opportunity.', + icon: 'IconMessage', + isNullable: true, + morphId: OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'lastContactItemMessageId', + }, + isUIEditable: false, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-event-started.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-event-started.test.ts index 320e99a023..34f62cad24 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-event-started.test.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-event-started.test.ts @@ -168,7 +168,9 @@ describe('on-calendar-event-started handler', () => { query.calendarEventParticipants.__args.filter.personId.eq, ), ).toEqual([PERSON_ID_1, PERSON_ID_2]); - expect(mutationMock).toHaveBeenCalledTimes(2); + expect( + mutationMock.mock.calls.filter(([mutation]) => mutation.updatePeople), + ).toHaveLength(2); }); it('should do nothing when no event started in the time window', async () => { diff --git a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-interaction.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-interaction.test.ts index 05dd1b2410..ee6d8d65f5 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-interaction.test.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-interaction.test.ts @@ -64,16 +64,19 @@ describe('on-calendar-interaction handler', () => { .mockResolvedValueOnce({ calendarEventParticipants: { edges: [] }, }) + .mockResolvedValueOnce({ person: null }) .mockResolvedValueOnce({ person: null }); await handler(buildEvent(PERSON_ID)); - expect(queryMock).toHaveBeenCalledTimes(3); + expect(queryMock).toHaveBeenCalledTimes(4); const queryArgs = queryMock.mock.calls[0][0]; expect(queryArgs.calendarEventParticipants.__args.filter.personId).toEqual( { eq: PERSON_ID }, ); - expect(mutationMock).toHaveBeenCalledTimes(1); + expect( + mutationMock.mock.calls.filter(([mutation]) => mutation.updatePeople), + ).toHaveLength(1); const mutationArgs = mutationMock.mock.calls[0][0]; expect(mutationArgs.updatePeople.__args.data).toEqual({ lastContactAt: PAST_EVENT_STARTS_AT, diff --git a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/backfill-last-contact.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/backfill-last-contact.ts index 9fd47a5530..cdcad1278d 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/backfill-last-contact.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/backfill-last-contact.ts @@ -17,6 +17,12 @@ type MeetingInteraction = { startsAt: string; }; type MessageMemberInfo = { ownerId: string; fromIsMember: boolean }; +type ContactItem = { kind: 'email' | 'meeting'; id: string }; +type LastContact = { at: string; item: ContactItem }; +type OpportunityRow = { + id: string; + pointOfContactId: string | null; +}; type PersonAgg = { lastContactAt?: string; @@ -30,6 +36,7 @@ type PersonAgg = { type AggByPersonId = Map; type PersonUpdateData = Record; +type RecordUpdate = { id: string; data: PersonUpdateData }; const chunk = (items: T[], size: number): T[][] => { const chunks: T[][] = []; @@ -237,6 +244,86 @@ const collectCalendarOwners = async ( return ownerByCalendarEventId; }; +const collectPersonCompanies = async ( + client: CoreApiClient, +): Promise> => { + const companyByPersonId = new Map(); + let after: string | undefined; + + do { + const { people } = await client.query({ + people: { + __args: { + filter: { companyId: { is: 'NOT_NULL' } }, + first: PAGE_SIZE, + after, + }, + edges: { node: { id: true, companyId: true } }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + + for (const edge of people?.edges ?? []) { + const { id, companyId } = edge.node; + if (id && companyId) { + companyByPersonId.set(id, companyId); + } + } + + after = people?.pageInfo.hasNextPage + ? (people.pageInfo.endCursor ?? undefined) + : undefined; + } while (after); + + return companyByPersonId; +}; + +const collectOpportunities = async ( + client: CoreApiClient, +): Promise => { + const opportunities: OpportunityRow[] = []; + let after: string | undefined; + + do { + const { opportunities: page } = await client.query({ + opportunities: { + __args: { first: PAGE_SIZE, after }, + edges: { + node: { id: true, pointOfContactId: true }, + }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + + for (const edge of page?.edges ?? []) { + const { id, pointOfContactId } = edge.node; + if (id) { + opportunities.push({ + id, + pointOfContactId: pointOfContactId ?? null, + }); + } + } + + after = page?.pageInfo.hasNextPage + ? (page.pageInfo.endCursor ?? undefined) + : undefined; + } while (after); + + return opportunities; +}; + +const buildRelatedData = ({ at, item }: LastContact): PersonUpdateData => ({ + lastContactAt: at, + lastContactItemMessageId: item.kind === 'email' ? item.id : null, + lastContactItemCalendarEventId: item.kind === 'meeting' ? item.id : null, +}); + +const personLastContact = (agg: PersonAgg): LastContact | undefined => + agg.lastContactAt && agg.item + ? { at: agg.lastContactAt, item: agg.item } + : undefined; + const foldEmail = ( agg: PersonAgg, receivedAt: string, @@ -306,12 +393,33 @@ const buildData = (agg: PersonAgg): PersonUpdateData => ({ : {}), }); +const applyUpdates = async ( + client: CoreApiClient, + mutationName: string, + updates: RecordUpdate[], +): Promise => { + for (const batch of chunk(updates, UPDATE_BATCH_SIZE)) { + await Promise.all( + batch.map(({ id, data }) => + client.mutation({ + [mutationName]: { + __args: { id, data }, + id: true, + }, + }), + ), + ); + } +}; + const handler = async (): Promise => { const client = new CoreApiClient(); - const [emails, meetings] = await Promise.all([ + const [emails, meetings, personCompanies, opportunities] = await Promise.all([ collectEmailInteractions(client), collectMeetingInteractions(client), + collectPersonCompanies(client), + collectOpportunities(client), ]); const messageIds = [...new Set(emails.map((email) => email.messageId))]; @@ -352,30 +460,56 @@ const handler = async (): Promise => { ); } - const updates = [...aggByPersonId.entries()].map(([personId, agg]) => ({ - personId, + const personUpdates = [...aggByPersonId.entries()].map(([personId, agg]) => ({ + id: personId, data: buildData(agg), })); - for (const batch of chunk(updates, UPDATE_BATCH_SIZE)) { - await Promise.all( - batch.map(({ personId, data }) => - client.mutation({ - updatePerson: { - __args: { id: personId, data }, - id: true, - }, - }), - ), - ); + const companyLastContact = new Map(); + for (const [personId, agg] of aggByPersonId) { + const contact = personLastContact(agg); + if (!contact) { + continue; + } + const companyId = personCompanies.get(personId); + if (!companyId) { + continue; + } + const existing = companyLastContact.get(companyId); + if (!existing || contact.at > existing.at) { + companyLastContact.set(companyId, contact); + } } + + const opportunityUpdates = opportunities + .map((opportunity): RecordUpdate | undefined => { + const pointOfContactAgg = opportunity.pointOfContactId + ? aggByPersonId.get(opportunity.pointOfContactId) + : undefined; + const lastContact = pointOfContactAgg ? personLastContact(pointOfContactAgg) : undefined; + return lastContact + ? { id: opportunity.id, data: buildRelatedData(lastContact) } + : undefined; + }) + .filter((update): update is RecordUpdate => Boolean(update)); + + const companyUpdates: RecordUpdate[] = [...companyLastContact.entries()].map( + ([companyId, contact]) => ({ + id: companyId, + data: buildRelatedData(contact), + }), + ); + + await applyUpdates(client, 'updatePerson', personUpdates); + await applyUpdates(client, 'updateCompany', companyUpdates); + await applyUpdates(client, 'updateOpportunity', opportunityUpdates); }; export default definePostInstallLogicFunction({ universalIdentifier: BACKFILL_POST_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, name: 'backfill-last-contact', description: - 'Fills person last-contact fields from existing messages and calendar events after installation.', + 'Fills person, company and opportunity last-contact fields from existing messages and calendar events after installation.', timeoutSeconds: 300, shouldRunOnVersionUpgrade: true, handler, diff --git a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-interaction.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-interaction.ts index ae28e4d6a8..003935794c 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-interaction.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-interaction.ts @@ -29,7 +29,7 @@ export default defineLogicFunction({ universalIdentifier: CALENDAR_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, name: 'on-calendar-interaction', description: - "Updates a person's last-contacted fields when a new calendar event participant is created (past events only).", + "Updates a person's last-contacted fields, and the last contact on their company and opportunities, when a new calendar event participant is created (past events only).", timeoutSeconds: 60, databaseEventTriggerSettings: { eventName: 'calendarEventParticipant.updated', diff --git a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-email-interaction.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-email-interaction.ts index 8b4c13d072..c0f3acfec7 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-email-interaction.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-email-interaction.ts @@ -5,6 +5,7 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; import { EMAIL_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; import { pickContactTeamMemberId } from 'src/utils/pick-contact-team-member'; import { updatePersonForInteraction } from 'src/utils/update-person-last-contact'; +import { updateRelatedLastContact } from 'src/utils/update-related-last-contact'; type MessageParticipantUpdate = { personId?: string | null; @@ -69,13 +70,20 @@ const handler = async ( workspaceMemberId, direction, }); + + await updateRelatedLastContact(client, { + personId, + occurredAt, + itemId: messageId, + kind: 'email', + }); }; export default defineLogicFunction({ universalIdentifier: EMAIL_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, name: 'on-email-interaction', description: - "Updates a person's last-contacted fields when a new email participant is created.", + "Updates a person's last-contacted fields, and the last contact on their company and opportunities, when a new email participant is created.", timeoutSeconds: 60, databaseEventTriggerSettings: { eventName: 'messageParticipant.updated', diff --git a/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-related-last-contact.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-related-last-contact.test.ts new file mode 100644 index 0000000000..9e622de635 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-related-last-contact.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { updateRelatedLastContact } from 'src/utils/update-related-last-contact'; + +const PERSON_ID = '11111111-1111-1111-1111-111111111111'; +const MESSAGE_ID = '22222222-2222-2222-2222-222222222222'; +const CALENDAR_EVENT_ID = '66666666-6666-6666-6666-666666666666'; +const COMPANY_ID = '33333333-3333-3333-3333-333333333333'; +const OCCURRED_AT = '2026-06-10T09:00:00.000Z'; + +type Client = { + query: ReturnType; + mutation: ReturnType; +}; + +const buildClient = (): Client => ({ + query: vi.fn(), + mutation: vi.fn().mockResolvedValue({}), +}); + +let client: Client; + +beforeEach(() => { + client = buildClient(); +}); + +describe('updateRelatedLastContact', () => { + it('updates the company and point-of-contact opportunities for an email', async () => { + client.query.mockResolvedValueOnce({ + person: { id: PERSON_ID, companyId: COMPANY_ID }, + }); + + await updateRelatedLastContact(client as never, { + personId: PERSON_ID, + occurredAt: OCCURRED_AT, + itemId: MESSAGE_ID, + kind: 'email', + }); + + const companyCall = client.mutation.mock.calls.find( + (call) => call[0].updateCompanies, + ); + expect(companyCall?.[0].updateCompanies.__args.data).toEqual({ + lastContactAt: OCCURRED_AT, + lastContactItemMessageId: MESSAGE_ID, + lastContactItemCalendarEventId: null, + }); + expect(companyCall?.[0].updateCompanies.__args.filter.and[0]).toEqual({ + id: { eq: COMPANY_ID }, + }); + + const opportunityCall = client.mutation.mock.calls.find( + (call) => call[0].updateOpportunities, + ); + expect(opportunityCall?.[0].updateOpportunities.__args.filter.and[0]).toEqual( + { pointOfContactId: { eq: PERSON_ID } }, + ); + expect(opportunityCall?.[0].updateOpportunities.__args.data).toEqual({ + lastContactAt: OCCURRED_AT, + lastContactItemMessageId: MESSAGE_ID, + lastContactItemCalendarEventId: null, + }); + }); + + it('sets the calendar event item for a meeting', async () => { + client.query.mockResolvedValueOnce({ + person: { id: PERSON_ID, companyId: COMPANY_ID }, + }); + + await updateRelatedLastContact(client as never, { + personId: PERSON_ID, + occurredAt: OCCURRED_AT, + itemId: CALENDAR_EVENT_ID, + kind: 'meeting', + }); + + const companyCall = client.mutation.mock.calls.find( + (call) => call[0].updateCompanies, + ); + expect(companyCall?.[0].updateCompanies.__args.data).toEqual({ + lastContactAt: OCCURRED_AT, + lastContactItemMessageId: null, + lastContactItemCalendarEventId: CALENDAR_EVENT_ID, + }); + }); + + it('only guards against newer contacts', async () => { + client.query.mockResolvedValueOnce({ + person: { id: PERSON_ID, companyId: COMPANY_ID }, + }); + + await updateRelatedLastContact(client as never, { + personId: PERSON_ID, + occurredAt: OCCURRED_AT, + itemId: MESSAGE_ID, + kind: 'email', + }); + + const expectedGuard = { + or: [ + { lastContactAt: { is: 'NULL' } }, + { lastContactAt: { lt: OCCURRED_AT } }, + ], + }; + const companyCall = client.mutation.mock.calls.find( + (call) => call[0].updateCompanies, + ); + expect(companyCall?.[0].updateCompanies.__args.filter.and[1]).toEqual( + expectedGuard, + ); + const opportunityCall = client.mutation.mock.calls.find( + (call) => call[0].updateOpportunities, + ); + expect(opportunityCall?.[0].updateOpportunities.__args.filter.and[1]).toEqual( + expectedGuard, + ); + }); + + it('skips the company update when the person has no company but still updates opportunities', async () => { + client.query.mockResolvedValueOnce({ + person: { id: PERSON_ID, companyId: null }, + }); + + await updateRelatedLastContact(client as never, { + personId: PERSON_ID, + occurredAt: OCCURRED_AT, + itemId: MESSAGE_ID, + kind: 'email', + }); + + expect( + client.mutation.mock.calls.some((call) => call[0].updateCompanies), + ).toBe(false); + const opportunityCall = client.mutation.mock.calls.find( + (call) => call[0].updateOpportunities, + ); + expect(opportunityCall?.[0].updateOpportunities.__args.filter.and[0]).toEqual( + { pointOfContactId: { eq: PERSON_ID } }, + ); + }); +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-from-calendar.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-from-calendar.ts index 6436557737..c674569736 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-from-calendar.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-from-calendar.ts @@ -2,6 +2,7 @@ import { type CoreApiClient } from 'twenty-client-sdk/core'; import { pickContactTeamMemberId } from 'src/utils/pick-contact-team-member'; import { updatePersonForInteraction } from 'src/utils/update-person-last-contact'; +import { updateRelatedLastContact } from 'src/utils/update-related-last-contact'; export const updatePersonLastContactFromCalendar = async ( client: CoreApiClient, @@ -77,4 +78,11 @@ export const updatePersonLastContactFromCalendar = async ( itemId: calendarEvent.id, workspaceMemberId, }); + + await updateRelatedLastContact(client, { + personId, + occurredAt, + itemId: calendarEvent.id, + kind: 'meeting', + }); }; diff --git a/packages/twenty-apps/public/twenty-last-contact/src/utils/update-related-last-contact.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/update-related-last-contact.ts new file mode 100644 index 0000000000..41beba83a4 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/utils/update-related-last-contact.ts @@ -0,0 +1,75 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { type InteractionKind } from 'src/utils/update-person-last-contact'; + +export type RelatedInteraction = { + personId: string; + occurredAt: string; + itemId: string; + kind: InteractionKind; +}; + +const recencyGuard = (occurredAt: string) => ({ + or: [ + { lastContactAt: { is: 'NULL' } }, + { lastContactAt: { lt: occurredAt } }, + ], +}); + +const buildData = ({ + occurredAt, + itemId, + kind, +}: Omit): Record => ({ + lastContactAt: occurredAt, + lastContactItemMessageId: kind === 'email' ? itemId : null, + lastContactItemCalendarEventId: kind === 'meeting' ? itemId : null, +}); + +// Companies and opportunities surface emails and meetings from their related +// people, so their last contact mirrors the most recent contact of any person +// connected to them. +export const updateRelatedLastContact = async ( + client: CoreApiClient, + { personId, occurredAt, itemId, kind }: RelatedInteraction, +): Promise => { + const personResult = await client.query({ + person: { + __args: { filter: { id: { eq: personId } } }, + id: true, + companyId: true, + }, + }); + + const companyId = + (personResult?.person as { companyId?: string | null } | null | undefined) + ?.companyId ?? null; + const data = buildData({ occurredAt, itemId, kind }); + + if (companyId) { + await client.mutation({ + updateCompanies: { + __args: { + data, + filter: { and: [{ id: { eq: companyId } }, recencyGuard(occurredAt)] }, + }, + id: true, + }, + }); + } + + await client.mutation({ + updateOpportunities: { + __args: { + data, + filter: { + and: [ + { pointOfContactId: { eq: personId } }, + recencyGuard(occurredAt), + ], + }, + }, + id: true, + }, + }); +}; diff --git a/packages/twenty-apps/public/twenty-last-contact/src/view-fields/company-last-contact-at.view-field.ts b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/company-last-contact-at.view-field.ts new file mode 100644 index 0000000000..e4046184f6 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/company-last-contact-at.view-field.ts @@ -0,0 +1,21 @@ +import { + defineViewField, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + COMPANY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER, + COMPANY_LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineViewField({ + universalIdentifier: COMPANY_LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER, + viewUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.views.allCompanies + .universalIdentifier, + fieldMetadataUniversalIdentifier: + COMPANY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER, + position: 8, + isVisible: true, + size: 150, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/view-fields/company-last-contact-item-calendar-event.view-field.ts b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/company-last-contact-item-calendar-event.view-field.ts new file mode 100644 index 0000000000..add53b477f --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/company-last-contact-item-calendar-event.view-field.ts @@ -0,0 +1,22 @@ +import { + defineViewField, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineViewField({ + universalIdentifier: + COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER, + viewUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.views.allCompanies + .universalIdentifier, + fieldMetadataUniversalIdentifier: + COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + position: 10, + isVisible: true, + size: 180, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/view-fields/company-last-contact-item-message.view-field.ts b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/company-last-contact-item-message.view-field.ts new file mode 100644 index 0000000000..0c32c366b7 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/company-last-contact-item-message.view-field.ts @@ -0,0 +1,22 @@ +import { + defineViewField, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + COMPANY_LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineViewField({ + universalIdentifier: + COMPANY_LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER, + viewUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.views.allCompanies + .universalIdentifier, + fieldMetadataUniversalIdentifier: + COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + position: 9, + isVisible: true, + size: 180, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/view-fields/opportunity-last-contact-at.view-field.ts b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/opportunity-last-contact-at.view-field.ts new file mode 100644 index 0000000000..2fd2586628 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/opportunity-last-contact-at.view-field.ts @@ -0,0 +1,22 @@ +import { + defineViewField, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + OPPORTUNITY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER, + OPPORTUNITY_LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineViewField({ + universalIdentifier: + OPPORTUNITY_LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER, + viewUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.allOpportunities + .universalIdentifier, + fieldMetadataUniversalIdentifier: + OPPORTUNITY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER, + position: 7, + isVisible: true, + size: 150, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/view-fields/opportunity-last-contact-item-calendar-event.view-field.ts b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/opportunity-last-contact-item-calendar-event.view-field.ts new file mode 100644 index 0000000000..b2a8b1069e --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/opportunity-last-contact-item-calendar-event.view-field.ts @@ -0,0 +1,22 @@ +import { + defineViewField, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineViewField({ + universalIdentifier: + OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER, + viewUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.allOpportunities + .universalIdentifier, + fieldMetadataUniversalIdentifier: + OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + position: 9, + isVisible: true, + size: 180, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/view-fields/opportunity-last-contact-item-message.view-field.ts b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/opportunity-last-contact-item-message.view-field.ts new file mode 100644 index 0000000000..c33dc85979 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/opportunity-last-contact-item-message.view-field.ts @@ -0,0 +1,22 @@ +import { + defineViewField, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineViewField({ + universalIdentifier: + OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER, + viewUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.allOpportunities + .universalIdentifier, + fieldMetadataUniversalIdentifier: + OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + position: 8, + isVisible: true, + size: 180, +});