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)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23569?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@twentyhq/last-contact",
|
||||
"version": "1.1.3",
|
||||
"version": "1.2.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
|
||||
+189
@@ -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<void>;
|
||||
const opportunityCreatedHandler = onOpportunityCreated.config.handler as (
|
||||
event: unknown,
|
||||
) => Promise<void>;
|
||||
const opportunityUpdatedHandler = onOpportunityUpdated.config.handler as (
|
||||
event: unknown,
|
||||
) => Promise<void>;
|
||||
const companyCreatedHandler = onCompanyCreated.config.handler as (
|
||||
event: unknown,
|
||||
) => Promise<void>;
|
||||
const personUpdatedHandler = onPersonUpdated.config.handler as (
|
||||
event: unknown,
|
||||
) => Promise<void>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
+63
@@ -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<void>;
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+73
@@ -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<void>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<ObjectRecordCreateEvent<CompanyCreate>>,
|
||||
): Promise<void> => {
|
||||
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,
|
||||
});
|
||||
+37
@@ -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<ObjectRecordCreateEvent<OpportunityCreate>>,
|
||||
): Promise<void> => {
|
||||
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,
|
||||
});
|
||||
+38
@@ -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<ObjectRecordUpdateEvent<OpportunityUpdate>>,
|
||||
): Promise<void> => {
|
||||
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,
|
||||
});
|
||||
@@ -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<ObjectRecordCreateEvent<PersonCreate>>,
|
||||
): Promise<void> => {
|
||||
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,
|
||||
});
|
||||
@@ -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<ObjectRecordUpdateEvent<PersonUpdate>>,
|
||||
): Promise<void> => {
|
||||
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,
|
||||
});
|
||||
+65
@@ -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<typeof vi.fn>;
|
||||
mutation: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+67
@@ -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<typeof vi.fn>;
|
||||
mutation: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<void> => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { type CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
type LastContactData = Record<string, string | null>;
|
||||
|
||||
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<void> => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user