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:
martmull
2026-07-30 16:24:05 +02:00
committed by GitHub
parent 2be01df271
commit e1b5edc07e
15 changed files with 785 additions and 1 deletions
@@ -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",
@@ -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';
@@ -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,
});
});
});
@@ -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,
});
@@ -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,
});
@@ -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,
});
@@ -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,
});
});
});
@@ -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,
},
});
};
@@ -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,
},
});
};