From e1b5edc07e0884e8d61807550f22f4960dc0d176 Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 30 Jul 2026 16:24:05 +0200 Subject: [PATCH] Compute last contact on relationship changes in last-contact app (#23569) ## What The `last-contact` app only refreshed the last contact on Companies and Opportunities when a new email or meeting arrived. When relationships changed but no interaction happened, those fields went stale: - Creating an opportunity with an existing point of contact left its last contact empty. - Changing an opportunity's point of contact kept the previous contact's value. - Assigning a person (who already had contact history) to a company never surfaced on the company. This adds logic functions that recompute the derived last-contact fields when the record or its relationships change. ## Changes New logic functions (auto-discovered): - `on-opportunity-created` (`opportunity.created`) and `on-opportunity-updated` (`opportunity.updated`, `pointOfContactId`) recompute an opportunity's last contact from its point of contact. - `on-company-created` (`company.created`) recomputes a company's last contact from its people. - `on-person-created` (`person.created`) and `on-person-updated` (`person.updated`, `companyId`) recompute the former and current company's last contact when a person joins or leaves. Shared helpers `recomputeOpportunityLastContact` and `recomputeCompanyLastContact` mirror the point-of-contact / most-recent-person value onto the record (clearing it when there is no contact). Reads use the morph relation subfield (`lastContactItemMessage { id }`), matching the existing integration-test read pattern. The `updatedFields` filters keep these off the interaction write path, so they never self-trigger. ## Tests - Unit tests for both recompute helpers and the new logic functions. - Integration tests covering opportunity-on-create, point-of-contact change, person joining/leaving a company, and the empty-company case. - `typecheck`, `lint`, and unit tests pass. --- _Generated by [Claude Code](https://claude.ai/code/session_014LMuzNLGYrL5eTkMB3UDRV)_ Review in cubic --- .../public/last-contact/CHANGELOG.md | 4 + .../public/last-contact/package.json | 2 +- .../last-contact.integration-test.ts | 189 ++++++++++++++++++ .../src/constants/universal-identifiers.ts | 10 + .../__tests__/on-opportunity-created.test.ts | 63 ++++++ .../__tests__/on-person-updated.test.ts | 73 +++++++ .../src/logic-functions/on-company-created.ts | 37 ++++ .../logic-functions/on-opportunity-created.ts | 37 ++++ .../logic-functions/on-opportunity-updated.ts | 38 ++++ .../src/logic-functions/on-person-created.ts | 37 ++++ .../src/logic-functions/on-person-updated.ts | 47 +++++ .../recompute-company-last-contact.test.ts | 65 ++++++ ...recompute-opportunity-last-contact.test.ts | 67 +++++++ .../utils/recompute-company-last-contact.ts | 52 +++++ .../recompute-opportunity-last-contact.ts | 65 ++++++ 15 files changed, 785 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-apps/public/last-contact/src/logic-functions/__tests__/on-opportunity-created.test.ts create mode 100644 packages/twenty-apps/public/last-contact/src/logic-functions/__tests__/on-person-updated.test.ts create mode 100644 packages/twenty-apps/public/last-contact/src/logic-functions/on-company-created.ts create mode 100644 packages/twenty-apps/public/last-contact/src/logic-functions/on-opportunity-created.ts create mode 100644 packages/twenty-apps/public/last-contact/src/logic-functions/on-opportunity-updated.ts create mode 100644 packages/twenty-apps/public/last-contact/src/logic-functions/on-person-created.ts create mode 100644 packages/twenty-apps/public/last-contact/src/logic-functions/on-person-updated.ts create mode 100644 packages/twenty-apps/public/last-contact/src/utils/__tests__/recompute-company-last-contact.test.ts create mode 100644 packages/twenty-apps/public/last-contact/src/utils/__tests__/recompute-opportunity-last-contact.test.ts create mode 100644 packages/twenty-apps/public/last-contact/src/utils/recompute-company-last-contact.ts create mode 100644 packages/twenty-apps/public/last-contact/src/utils/recompute-opportunity-last-contact.ts diff --git a/packages/twenty-apps/public/last-contact/CHANGELOG.md b/packages/twenty-apps/public/last-contact/CHANGELOG.md index 270538145f..f02b8d778b 100644 --- a/packages/twenty-apps/public/last-contact/CHANGELOG.md +++ b/packages/twenty-apps/public/last-contact/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.2.0 + +- Compute last contact on Companies and Opportunities when the record or its relationships change, not only on new interactions: opportunities recompute from their point of contact on creation and when it changes, and companies recompute from their people on creation and when a person joins or leaves. + ## 1.1.3 - Stop declaring INDEX view fields explicitly: the server now provisions the INDEX view column for each app field automatically, so the manifest no longer targets the engine-owned standard INDEX views. diff --git a/packages/twenty-apps/public/last-contact/package.json b/packages/twenty-apps/public/last-contact/package.json index 313abbed97..5cf28afce1 100644 --- a/packages/twenty-apps/public/last-contact/package.json +++ b/packages/twenty-apps/public/last-contact/package.json @@ -1,6 +1,6 @@ { "name": "@twentyhq/last-contact", - "version": "1.1.3", + "version": "1.2.0", "license": "MIT", "engines": { "node": "^24.5.0", diff --git a/packages/twenty-apps/public/last-contact/src/__tests__/last-contact.integration-test.ts b/packages/twenty-apps/public/last-contact/src/__tests__/last-contact.integration-test.ts index f55048027e..8b772ee496 100644 --- a/packages/twenty-apps/public/last-contact/src/__tests__/last-contact.integration-test.ts +++ b/packages/twenty-apps/public/last-contact/src/__tests__/last-contact.integration-test.ts @@ -4,7 +4,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; import onCalendarInteraction from 'src/logic-functions/on-calendar-interaction'; +import onCompanyCreated from 'src/logic-functions/on-company-created'; import onEmailInteraction from 'src/logic-functions/on-email-interaction'; +import onOpportunityCreated from 'src/logic-functions/on-opportunity-created'; +import onOpportunityUpdated from 'src/logic-functions/on-opportunity-updated'; +import onPersonUpdated from 'src/logic-functions/on-person-updated'; const calendarHandler = onCalendarInteraction.config.handler as ( event: unknown, @@ -12,6 +16,18 @@ const calendarHandler = onCalendarInteraction.config.handler as ( const emailHandler = onEmailInteraction.config.handler as ( event: unknown, ) => Promise; +const opportunityCreatedHandler = onOpportunityCreated.config.handler as ( + event: unknown, +) => Promise; +const opportunityUpdatedHandler = onOpportunityUpdated.config.handler as ( + event: unknown, +) => Promise; +const companyCreatedHandler = onCompanyCreated.config.handler as ( + event: unknown, +) => Promise; +const personUpdatedHandler = onPersonUpdated.config.handler as ( + event: unknown, +) => Promise; const DAY_IN_MS = 24 * 60 * 60 * 1000; @@ -994,4 +1010,177 @@ describe('last contact handlers', () => { lastMeetingId: calendarEventId, }); }); + + it('computes an opportunity last contact from its point of contact on creation', async () => { + const workspaceMemberId = await getWorkspaceMemberId(client); + const personId = await createPerson(client); + createdPersonIds.push(personId); + const receivedAt = new Date(Date.now() - 2 * DAY_IN_MS).toISOString(); + + const messageId = await recordEmail({ + personId, + workspaceMemberId, + receivedAt, + direction: 'outbound', + }); + + const opportunityId = await createOpportunity(client, { + pointOfContactId: personId, + }); + createdOpportunityIds.push(opportunityId); + + await opportunityCreatedHandler({ + recordId: opportunityId, + properties: { after: { id: opportunityId } }, + }); + + const opportunityContact = await getRelatedLastContact( + client, + 'opportunity', + opportunityId, + ); + expect(asTime(opportunityContact.lastContactAt)).toBe(asTime(receivedAt)); + expect(opportunityContact.lastContactItemMessageId).toBe(messageId); + expect(opportunityContact.lastContactItemCalendarEventId).toBeNull(); + }); + + it('recomputes an opportunity last contact when the point of contact changes', async () => { + const workspaceMemberId = await getWorkspaceMemberId(client); + const contactedPersonId = await createPerson(client); + createdPersonIds.push(contactedPersonId); + const uncontactedPersonId = await createPerson(client); + createdPersonIds.push(uncontactedPersonId); + const receivedAt = new Date(Date.now() - 2 * DAY_IN_MS).toISOString(); + + const messageId = await recordEmail({ + personId: contactedPersonId, + workspaceMemberId, + receivedAt, + direction: 'outbound', + }); + + const opportunityId = await createOpportunity(client, { + pointOfContactId: uncontactedPersonId, + }); + createdOpportunityIds.push(opportunityId); + + await client.mutation({ + updateOpportunity: { + __args: { + id: opportunityId, + data: { pointOfContactId: contactedPersonId }, + }, + id: true, + }, + }); + await opportunityUpdatedHandler({ + recordId: opportunityId, + properties: { + updatedFields: ['pointOfContactId'], + before: { id: opportunityId }, + after: { id: opportunityId }, + }, + }); + + const opportunityContact = await getRelatedLastContact( + client, + 'opportunity', + opportunityId, + ); + expect(asTime(opportunityContact.lastContactAt)).toBe(asTime(receivedAt)); + expect(opportunityContact.lastContactItemMessageId).toBe(messageId); + }); + + it("recomputes a company last contact when a person joins it after being contacted", async () => { + const workspaceMemberId = await getWorkspaceMemberId(client); + const companyId = await createCompany(client); + createdCompanyIds.push(companyId); + const personId = await createPerson(client); + createdPersonIds.push(personId); + const receivedAt = new Date(Date.now() - 2 * DAY_IN_MS).toISOString(); + + const messageId = await recordEmail({ + personId, + workspaceMemberId, + receivedAt, + direction: 'outbound', + }); + + await setPersonCompany(client, { personId, companyId }); + await personUpdatedHandler({ + recordId: personId, + properties: { + updatedFields: ['companyId'], + before: { id: personId, companyId: null }, + after: { id: personId, companyId }, + }, + }); + + const companyContact = await getRelatedLastContact( + client, + 'company', + companyId, + ); + expect(asTime(companyContact.lastContactAt)).toBe(asTime(receivedAt)); + expect(companyContact.lastContactItemMessageId).toBe(messageId); + expect(companyContact.lastContactItemCalendarEventId).toBeNull(); + }); + + it('clears a company last contact when its only contacted person leaves', 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 receivedAt = new Date(Date.now() - 2 * DAY_IN_MS).toISOString(); + + await recordEmail({ + personId, + workspaceMemberId, + receivedAt, + direction: 'outbound', + }); + + await client.mutation({ + updatePerson: { + __args: { id: personId, data: { companyId: null } }, + id: true, + }, + }); + await personUpdatedHandler({ + recordId: personId, + properties: { + updatedFields: ['companyId'], + before: { id: personId, companyId }, + after: { id: personId, companyId: null }, + }, + }); + + const companyContact = await getRelatedLastContact( + client, + 'company', + companyId, + ); + expect(companyContact.lastContactAt).toBeNull(); + expect(companyContact.lastContactItemMessageId).toBeNull(); + expect(companyContact.lastContactItemCalendarEventId).toBeNull(); + }); + + it('leaves the company last contact empty when the company has no contacted people on creation', async () => { + const companyId = await createCompany(client); + createdCompanyIds.push(companyId); + + await companyCreatedHandler({ + recordId: companyId, + properties: { after: { id: companyId } }, + }); + + const companyContact = await getRelatedLastContact( + client, + 'company', + companyId, + ); + expect(companyContact.lastContactAt).toBeNull(); + }); }); diff --git a/packages/twenty-apps/public/last-contact/src/constants/universal-identifiers.ts b/packages/twenty-apps/public/last-contact/src/constants/universal-identifiers.ts index 3c87d5619e..f024a43b9f 100644 --- a/packages/twenty-apps/public/last-contact/src/constants/universal-identifiers.ts +++ b/packages/twenty-apps/public/last-contact/src/constants/universal-identifiers.ts @@ -13,6 +13,16 @@ export const BACKFILL_POST_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = 'c94f671f-b3fa-47a2-8de6-dde94d13f8d1'; export const CALENDAR_EVENT_STARTED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = 'c56013d7-208b-46e2-a91f-27f481645591'; +export const OPPORTUNITY_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '6659b54f-2f46-412b-aa7d-03aa9f1c5133'; +export const OPPORTUNITY_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '071a1c3e-b580-4312-806d-ff27287292ae'; +export const COMPANY_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + 'd0631934-2f06-4799-9b33-6937f78e16ff'; +export const PERSON_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + 'cf7460cc-4c48-4412-9a1a-0c30e4960fce'; +export const PERSON_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + 'a967064f-f440-43cd-bfbe-907c95d4563f'; export const LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER = 'cfdee7bd-8d41-41e6-a888-512705e75d7b'; diff --git a/packages/twenty-apps/public/last-contact/src/logic-functions/__tests__/on-opportunity-created.test.ts b/packages/twenty-apps/public/last-contact/src/logic-functions/__tests__/on-opportunity-created.test.ts new file mode 100644 index 0000000000..6fef3b0dab --- /dev/null +++ b/packages/twenty-apps/public/last-contact/src/logic-functions/__tests__/on-opportunity-created.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { queryMock, mutationMock } = vi.hoisted(() => ({ + queryMock: vi.fn(), + mutationMock: vi.fn(), +})); +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: vi.fn(function () { + return { query: queryMock, mutation: mutationMock }; + }), +})); + +import onOpportunityCreated from '../on-opportunity-created'; + +const OPPORTUNITY_ID = '11111111-1111-1111-1111-111111111111'; +const PERSON_ID = '22222222-2222-2222-2222-222222222222'; +const MESSAGE_ID = '33333333-3333-3333-3333-333333333333'; +const OCCURRED_AT = '2026-06-10T09:00:00.000Z'; + +const handler = onOpportunityCreated.config.handler as ( + event: unknown, +) => Promise; + +beforeEach(() => { + queryMock.mockReset(); + mutationMock.mockReset(); + mutationMock.mockResolvedValue({}); +}); + +describe('on-opportunity-created', () => { + it('should trigger on opportunity creation', () => { + expect(onOpportunityCreated.success).toBe(true); + expect(onOpportunityCreated.config.databaseEventTriggerSettings).toEqual({ + eventName: 'opportunity.created', + }); + }); + + it('computes the last contact from the point of contact', async () => { + queryMock + .mockResolvedValueOnce({ + opportunity: { id: OPPORTUNITY_ID, pointOfContactId: PERSON_ID }, + }) + .mockResolvedValueOnce({ + person: { + id: PERSON_ID, + lastContactAt: OCCURRED_AT, + lastContactItemMessage: { id: MESSAGE_ID }, + lastContactItemCalendarEvent: null, + }, + }); + + await handler({ + recordId: OPPORTUNITY_ID, + properties: { after: { id: OPPORTUNITY_ID } }, + }); + + expect(mutationMock.mock.calls[0][0].updateOpportunity.__args.data).toEqual({ + lastContactAt: OCCURRED_AT, + lastContactItemMessageId: MESSAGE_ID, + lastContactItemCalendarEventId: null, + }); + }); +}); diff --git a/packages/twenty-apps/public/last-contact/src/logic-functions/__tests__/on-person-updated.test.ts b/packages/twenty-apps/public/last-contact/src/logic-functions/__tests__/on-person-updated.test.ts new file mode 100644 index 0000000000..fde8a05689 --- /dev/null +++ b/packages/twenty-apps/public/last-contact/src/logic-functions/__tests__/on-person-updated.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { queryMock, mutationMock } = vi.hoisted(() => ({ + queryMock: vi.fn(), + mutationMock: vi.fn(), +})); +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: vi.fn(function () { + return { query: queryMock, mutation: mutationMock }; + }), +})); + +import onPersonUpdated from '../on-person-updated'; + +const OLD_COMPANY_ID = '11111111-1111-1111-1111-111111111111'; +const NEW_COMPANY_ID = '22222222-2222-2222-2222-222222222222'; + +const handler = onPersonUpdated.config.handler as ( + event: unknown, +) => Promise; + +const buildEvent = (before: string | null, after: string | null) => ({ + recordId: 'person-1', + properties: { + updatedFields: ['companyId'], + before: { id: 'person-1', companyId: before }, + after: { id: 'person-1', companyId: after }, + }, +}); + +beforeEach(() => { + queryMock.mockReset(); + queryMock.mockResolvedValue({ people: { edges: [] } }); + mutationMock.mockReset(); + mutationMock.mockResolvedValue({}); +}); + +describe('on-person-updated definition', () => { + it('should trigger on companyId updates', () => { + expect(onPersonUpdated.success).toBe(true); + expect(onPersonUpdated.config.databaseEventTriggerSettings).toEqual({ + eventName: 'person.updated', + updatedFields: ['companyId'], + }); + }); +}); + +describe('on-person-updated handler', () => { + it('recomputes both the former and the current company', async () => { + await handler(buildEvent(OLD_COMPANY_ID, NEW_COMPANY_ID)); + + const updatedCompanyIds = mutationMock.mock.calls.map( + ([mutation]) => mutation.updateCompany.__args.id, + ); + expect(updatedCompanyIds).toEqual([OLD_COMPANY_ID, NEW_COMPANY_ID]); + }); + + it('recomputes a single company when the person only gains one', async () => { + await handler(buildEvent(null, NEW_COMPANY_ID)); + + expect(mutationMock).toHaveBeenCalledTimes(1); + expect(mutationMock.mock.calls[0][0].updateCompany.__args.id).toBe( + NEW_COMPANY_ID, + ); + }); + + it('does nothing when no company is involved', async () => { + await handler(buildEvent(null, null)); + + expect(queryMock).not.toHaveBeenCalled(); + expect(mutationMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-apps/public/last-contact/src/logic-functions/on-company-created.ts b/packages/twenty-apps/public/last-contact/src/logic-functions/on-company-created.ts new file mode 100644 index 0000000000..970ab86110 --- /dev/null +++ b/packages/twenty-apps/public/last-contact/src/logic-functions/on-company-created.ts @@ -0,0 +1,37 @@ +import { + defineLogicFunction, + type ObjectRecordCreateEvent, +} from 'twenty-sdk/define'; +import { type DatabaseEventPayload } from 'twenty-sdk/logic-function'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { COMPANY_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { recomputeCompanyLastContact } from 'src/utils/recompute-company-last-contact'; + +type CompanyCreate = { id?: string | null }; + +const handler = async ( + event: DatabaseEventPayload>, +): Promise => { + const companyId = event.properties.after.id ?? event.recordId; + + if (!companyId) { + return; + } + + const client = new CoreApiClient(); + + await recomputeCompanyLastContact(client, companyId); +}; + +export default defineLogicFunction({ + universalIdentifier: COMPANY_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'on-company-created', + description: + "Computes a company's last contact from its people when the company is created.", + timeoutSeconds: 60, + databaseEventTriggerSettings: { + eventName: 'company.created', + }, + handler, +}); diff --git a/packages/twenty-apps/public/last-contact/src/logic-functions/on-opportunity-created.ts b/packages/twenty-apps/public/last-contact/src/logic-functions/on-opportunity-created.ts new file mode 100644 index 0000000000..cbdee842d4 --- /dev/null +++ b/packages/twenty-apps/public/last-contact/src/logic-functions/on-opportunity-created.ts @@ -0,0 +1,37 @@ +import { + defineLogicFunction, + type ObjectRecordCreateEvent, +} from 'twenty-sdk/define'; +import { type DatabaseEventPayload } from 'twenty-sdk/logic-function'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { OPPORTUNITY_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { recomputeOpportunityLastContact } from 'src/utils/recompute-opportunity-last-contact'; + +type OpportunityCreate = { id?: string | null }; + +const handler = async ( + event: DatabaseEventPayload>, +): Promise => { + const opportunityId = event.properties.after.id ?? event.recordId; + + if (!opportunityId) { + return; + } + + const client = new CoreApiClient(); + + await recomputeOpportunityLastContact(client, opportunityId); +}; + +export default defineLogicFunction({ + universalIdentifier: OPPORTUNITY_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'on-opportunity-created', + description: + "Computes an opportunity's last contact from its point of contact when the opportunity is created.", + timeoutSeconds: 60, + databaseEventTriggerSettings: { + eventName: 'opportunity.created', + }, + handler, +}); diff --git a/packages/twenty-apps/public/last-contact/src/logic-functions/on-opportunity-updated.ts b/packages/twenty-apps/public/last-contact/src/logic-functions/on-opportunity-updated.ts new file mode 100644 index 0000000000..3520592e12 --- /dev/null +++ b/packages/twenty-apps/public/last-contact/src/logic-functions/on-opportunity-updated.ts @@ -0,0 +1,38 @@ +import { + defineLogicFunction, + type ObjectRecordUpdateEvent, +} from 'twenty-sdk/define'; +import { type DatabaseEventPayload } from 'twenty-sdk/logic-function'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { OPPORTUNITY_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { recomputeOpportunityLastContact } from 'src/utils/recompute-opportunity-last-contact'; + +type OpportunityUpdate = { id?: string | null }; + +const handler = async ( + event: DatabaseEventPayload>, +): Promise => { + const opportunityId = event.properties.after?.id ?? event.recordId; + + if (!opportunityId) { + return; + } + + const client = new CoreApiClient(); + + await recomputeOpportunityLastContact(client, opportunityId); +}; + +export default defineLogicFunction({ + universalIdentifier: OPPORTUNITY_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'on-opportunity-updated', + description: + "Recomputes an opportunity's last contact from its point of contact when the point of contact changes.", + timeoutSeconds: 60, + databaseEventTriggerSettings: { + eventName: 'opportunity.updated', + updatedFields: ['pointOfContactId'], + }, + handler, +}); diff --git a/packages/twenty-apps/public/last-contact/src/logic-functions/on-person-created.ts b/packages/twenty-apps/public/last-contact/src/logic-functions/on-person-created.ts new file mode 100644 index 0000000000..edf900a04a --- /dev/null +++ b/packages/twenty-apps/public/last-contact/src/logic-functions/on-person-created.ts @@ -0,0 +1,37 @@ +import { + defineLogicFunction, + type ObjectRecordCreateEvent, +} from 'twenty-sdk/define'; +import { type DatabaseEventPayload } from 'twenty-sdk/logic-function'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { PERSON_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { recomputeCompanyLastContact } from 'src/utils/recompute-company-last-contact'; + +type PersonCreate = { companyId?: string | null }; + +const handler = async ( + event: DatabaseEventPayload>, +): Promise => { + const companyId = event.properties.after.companyId; + + if (!companyId) { + return; + } + + const client = new CoreApiClient(); + + await recomputeCompanyLastContact(client, companyId); +}; + +export default defineLogicFunction({ + universalIdentifier: PERSON_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'on-person-created', + description: + "Recomputes the company's last contact when a person is created with a company.", + timeoutSeconds: 60, + databaseEventTriggerSettings: { + eventName: 'person.created', + }, + handler, +}); diff --git a/packages/twenty-apps/public/last-contact/src/logic-functions/on-person-updated.ts b/packages/twenty-apps/public/last-contact/src/logic-functions/on-person-updated.ts new file mode 100644 index 0000000000..c8c2e3ccae --- /dev/null +++ b/packages/twenty-apps/public/last-contact/src/logic-functions/on-person-updated.ts @@ -0,0 +1,47 @@ +import { + defineLogicFunction, + type ObjectRecordUpdateEvent, +} from 'twenty-sdk/define'; +import { type DatabaseEventPayload } from 'twenty-sdk/logic-function'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { PERSON_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { recomputeCompanyLastContact } from 'src/utils/recompute-company-last-contact'; + +type PersonUpdate = { companyId?: string | null }; + +const handler = async ( + event: DatabaseEventPayload>, +): Promise => { + const before = event.properties.before?.companyId ?? null; + const after = event.properties.after?.companyId ?? null; + + const companyIds = [...new Set([before, after])].filter( + (id): id is string => Boolean(id), + ); + + if (companyIds.length === 0) { + return; + } + + const client = new CoreApiClient(); + + // Both the person's former and current company can lose or gain their most + // recent contact when the person moves. + for (const companyId of companyIds) { + await recomputeCompanyLastContact(client, companyId); + } +}; + +export default defineLogicFunction({ + universalIdentifier: PERSON_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'on-person-updated', + description: + "Recomputes the former and current company's last contact when a person's company changes.", + timeoutSeconds: 60, + databaseEventTriggerSettings: { + eventName: 'person.updated', + updatedFields: ['companyId'], + }, + handler, +}); diff --git a/packages/twenty-apps/public/last-contact/src/utils/__tests__/recompute-company-last-contact.test.ts b/packages/twenty-apps/public/last-contact/src/utils/__tests__/recompute-company-last-contact.test.ts new file mode 100644 index 0000000000..8acbc89d7f --- /dev/null +++ b/packages/twenty-apps/public/last-contact/src/utils/__tests__/recompute-company-last-contact.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { recomputeCompanyLastContact } from 'src/utils/recompute-company-last-contact'; + +const COMPANY_ID = '11111111-1111-1111-1111-111111111111'; +const CALENDAR_EVENT_ID = '44444444-4444-4444-4444-444444444444'; +const OCCURRED_AT = '2026-06-10T09:00:00.000Z'; + +type Client = { + query: ReturnType; + mutation: ReturnType; +}; + +let client: Client; + +beforeEach(() => { + client = { + query: vi.fn(), + mutation: vi.fn().mockResolvedValue({}), + }; +}); + +describe('recomputeCompanyLastContact', () => { + it('mirrors the most recent contact among the company people', async () => { + client.query.mockResolvedValueOnce({ + people: { + edges: [ + { + node: { + lastContactAt: OCCURRED_AT, + lastContactItemMessage: null, + lastContactItemCalendarEvent: { id: CALENDAR_EVENT_ID }, + }, + }, + ], + }, + }); + + await recomputeCompanyLastContact(client as never, COMPANY_ID); + + const args = client.mutation.mock.calls[0][0].updateCompany.__args; + expect(args.id).toBe(COMPANY_ID); + expect(args.data).toEqual({ + lastContactAt: OCCURRED_AT, + lastContactItemMessageId: null, + lastContactItemCalendarEventId: CALENDAR_EVENT_ID, + }); + expect(client.query.mock.calls[0][0].people.__args.filter).toEqual({ + companyId: { eq: COMPANY_ID }, + lastContactAt: { is: 'NOT_NULL' }, + }); + }); + + it('clears the company last contact when no person has a contact', async () => { + client.query.mockResolvedValueOnce({ people: { edges: [] } }); + + await recomputeCompanyLastContact(client as never, COMPANY_ID); + + expect(client.mutation.mock.calls[0][0].updateCompany.__args.data).toEqual({ + lastContactAt: null, + lastContactItemMessageId: null, + lastContactItemCalendarEventId: null, + }); + }); +}); diff --git a/packages/twenty-apps/public/last-contact/src/utils/__tests__/recompute-opportunity-last-contact.test.ts b/packages/twenty-apps/public/last-contact/src/utils/__tests__/recompute-opportunity-last-contact.test.ts new file mode 100644 index 0000000000..8451c7d277 --- /dev/null +++ b/packages/twenty-apps/public/last-contact/src/utils/__tests__/recompute-opportunity-last-contact.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { recomputeOpportunityLastContact } from 'src/utils/recompute-opportunity-last-contact'; + +const OPPORTUNITY_ID = '11111111-1111-1111-1111-111111111111'; +const PERSON_ID = '22222222-2222-2222-2222-222222222222'; +const MESSAGE_ID = '33333333-3333-3333-3333-333333333333'; +const OCCURRED_AT = '2026-06-10T09:00:00.000Z'; + +type Client = { + query: ReturnType; + mutation: ReturnType; +}; + +let client: Client; + +beforeEach(() => { + client = { + query: vi.fn(), + mutation: vi.fn().mockResolvedValue({}), + }; +}); + +describe('recomputeOpportunityLastContact', () => { + it('mirrors the point of contact last contact onto the opportunity', async () => { + client.query + .mockResolvedValueOnce({ + opportunity: { id: OPPORTUNITY_ID, pointOfContactId: PERSON_ID }, + }) + .mockResolvedValueOnce({ + person: { + id: PERSON_ID, + lastContactAt: OCCURRED_AT, + lastContactItemMessage: { id: MESSAGE_ID }, + lastContactItemCalendarEvent: null, + }, + }); + + await recomputeOpportunityLastContact(client as never, OPPORTUNITY_ID); + + expect(client.mutation.mock.calls[0][0].updateOpportunity.__args).toEqual({ + id: OPPORTUNITY_ID, + data: { + lastContactAt: OCCURRED_AT, + lastContactItemMessageId: MESSAGE_ID, + lastContactItemCalendarEventId: null, + }, + }); + }); + + it('clears the opportunity last contact when there is no point of contact', async () => { + client.query.mockResolvedValueOnce({ + opportunity: { id: OPPORTUNITY_ID, pointOfContactId: null }, + }); + + await recomputeOpportunityLastContact(client as never, OPPORTUNITY_ID); + + expect(client.query).toHaveBeenCalledTimes(1); + expect(client.mutation.mock.calls[0][0].updateOpportunity.__args.data).toEqual( + { + lastContactAt: null, + lastContactItemMessageId: null, + lastContactItemCalendarEventId: null, + }, + ); + }); +}); diff --git a/packages/twenty-apps/public/last-contact/src/utils/recompute-company-last-contact.ts b/packages/twenty-apps/public/last-contact/src/utils/recompute-company-last-contact.ts new file mode 100644 index 0000000000..669c96e458 --- /dev/null +++ b/packages/twenty-apps/public/last-contact/src/utils/recompute-company-last-contact.ts @@ -0,0 +1,52 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +type PersonNode = { + lastContactAt?: string | null; + lastContactItemMessage?: { id: string } | null; + lastContactItemCalendarEvent?: { id: string } | null; +}; + +// A company's last contact mirrors the most recent contact of any of its people, +// so it must be recomputed whenever that set of people changes rather than only +// when an interaction happens. +export const recomputeCompanyLastContact = async ( + client: CoreApiClient, + companyId: string, +): Promise => { + const { people } = await client.query({ + people: { + __args: { + filter: { + companyId: { eq: companyId }, + lastContactAt: { is: 'NOT_NULL' }, + }, + orderBy: [{ lastContactAt: 'DescNullsLast' }], + first: 1, + }, + edges: { + node: { + lastContactAt: true, + lastContactItemMessage: { id: true }, + lastContactItemCalendarEvent: { id: true }, + }, + }, + }, + }); + + const topPerson = (people?.edges?.[0]?.node as PersonNode | undefined) ?? {}; + + await client.mutation({ + updateCompany: { + __args: { + id: companyId, + data: { + lastContactAt: topPerson.lastContactAt ?? null, + lastContactItemMessageId: topPerson.lastContactItemMessage?.id ?? null, + lastContactItemCalendarEventId: + topPerson.lastContactItemCalendarEvent?.id ?? null, + }, + }, + id: true, + }, + }); +}; diff --git a/packages/twenty-apps/public/last-contact/src/utils/recompute-opportunity-last-contact.ts b/packages/twenty-apps/public/last-contact/src/utils/recompute-opportunity-last-contact.ts new file mode 100644 index 0000000000..d04bb78b1b --- /dev/null +++ b/packages/twenty-apps/public/last-contact/src/utils/recompute-opportunity-last-contact.ts @@ -0,0 +1,65 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +type LastContactData = Record; + +const EMPTY_LAST_CONTACT: LastContactData = { + lastContactAt: null, + lastContactItemMessageId: null, + lastContactItemCalendarEventId: null, +}; + +type PersonLastContact = { + lastContactAt?: string | null; + lastContactItemMessage?: { id: string } | null; + lastContactItemCalendarEvent?: { id: string } | null; +}; + +// An opportunity's last contact mirrors its point of contact, so it must be +// recomputed whenever the opportunity is created or its point of contact changes, +// not only when an interaction happens. +export const recomputeOpportunityLastContact = async ( + client: CoreApiClient, + opportunityId: string, +): Promise => { + const { opportunity } = await client.query({ + opportunity: { + __args: { filter: { id: { eq: opportunityId } } }, + id: true, + pointOfContactId: true, + }, + }); + + const pointOfContactId = + (opportunity as { pointOfContactId?: string | null } | null | undefined) + ?.pointOfContactId ?? null; + + let data: LastContactData = EMPTY_LAST_CONTACT; + + if (pointOfContactId) { + const { person } = await client.query({ + person: { + __args: { filter: { id: { eq: pointOfContactId } } }, + id: true, + lastContactAt: true, + lastContactItemMessage: { id: true }, + lastContactItemCalendarEvent: { id: true }, + }, + }); + + const current = (person ?? {}) as PersonLastContact; + + data = { + lastContactAt: current.lastContactAt ?? null, + lastContactItemMessageId: current.lastContactItemMessage?.id ?? null, + lastContactItemCalendarEventId: + current.lastContactItemCalendarEvent?.id ?? null, + }; + } + + await client.mutation({ + updateOpportunity: { + __args: { id: opportunityId, data }, + id: true, + }, + }); +};