Add Last contact on Companies and Opportunities to last-contact app (#22720)
## After Example with google company, 2 contacts, 2 opportunities: <img width="1512" height="332" alt="image" src="https://github.com/user-attachments/assets/9dac3ac1-bbbb-41a4-b6d6-5f2180e33c13"/> <img width="1512" height="313" alt="image" src="https://github.com/user-attachments/assets/e6e326db-942f-45c2-a696-05fa4c32619b"/> <img width="1309" height="313" alt="image" src="https://github.com/user-attachments/assets/64ed84ab-f7f0-47e5-b48c-42572c7d2534"/> ## What Extends the `twenty-last-contact` app so **Last contact** is also surfaced on **Companies** and **Opportunities**, not just People. Feedback: Companies and Opportunities already show emails and meetings from their related Person records on their timeline, so they should expose the most recent touch as fields too. Terminology and mechanism intentionally mirror what the app already does on People (no email-specific fields). ## Changes - **New fields**, identical in name/label/semantics to the People headline columns: - `Company.lastContactAt` and `Opportunity.lastContactAt` (datetime, "Last contact") - `Company.lastContactItemMessage` / `lastContactItemCalendarEvent` and the same pair on Opportunity (morph relation, "Last contact item"), with inverse `lastContactForCompanies` / `lastContactForOpportunities` relations on Message and Calendar event - All app fields are read-only in the UI (`isUIEditable: false`) - **View fields** on the All Companies and All Opportunities views (Last contact + Last contact item, visible). - **Live updates**: the new `updateRelatedLastContact` util propagates a person's interaction to their company and their point-of-contact opportunities, guarded so an older interaction never overwrites a newer one. It is called from both the email handler (`on-email-interaction`) and the shared calendar path (`updatePersonLastContactFromCalendar`, used by `on-calendar-interaction` and `on-calendar-event-started`), so emails and meetings both count. - **Backfill** (`backfill-last-contact`): aggregates each person's last contact up to their company, and each opportunity's from its point of contact. Related-record scope: a company's last contact comes from its people; an opportunity's from its point of contact. ## Not included (deliberately) The directional fields (`lastInboundAt`, `lastOutboundAt`, `lastContactBy`) and the `lastEmail`/`lastMeeting` shortcuts are not mirrored: aggregated across many people they get semantically fuzzy, and they would double the write amplification on every synced email for little added signal. ## Tests - Unit tests for `updateRelatedLastContact` (email and meeting propagation, recency guard, no-company case). - Integration tests: a related person's email sets company + opportunity last contact; a later meeting supersedes an email; an older interaction does not overwrite a newer one. - Existing unit + integration tests updated and passing; typecheck and lint clean.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.0
|
||||
|
||||
- Add last contact on Companies and Opportunities.
|
||||
- Set last-contact fields readonly.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- Initial release: "Last contact by" and "Last contact item" tracking on People, powered by calendar and message sync.
|
||||
@@ -4,17 +4,20 @@
|
||||
|
||||
## ✨ What you get
|
||||
|
||||
- **Seven live columns** — last contact, who reached out (you or them), the owning teammate, and the exact email or meeting behind it
|
||||
- **Live columns on People, Companies and Opportunities** — last contact, who reached out (you or them), the owning teammate, and the exact email or meeting behind it
|
||||
- **Zero upkeep** — updates in real time from every synced email and meeting, with your full history backfilled the moment you install
|
||||
- **Follow-ups made obvious** — sort by recency to catch cold relationships and see who owes whom a reply
|
||||
|
||||
## 📊 The columns
|
||||
|
||||
On **People** you get:
|
||||
- **Last contact** — the most recent touch, either direction
|
||||
- **Last outbound** / **Last inbound** — when you last reached out vs. when they last did
|
||||
- **Last contact by** — the teammate connected to this person
|
||||
- **Last contact item** / **Last email** / **Last meeting** — one click to the actual record
|
||||
|
||||
On **Companies** and **Opportunities** you also get **Last contact** and **Last contact item** columns.
|
||||
|
||||
## 💳 Billing
|
||||
|
||||
**Free** — no credits, no metering.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@twentyhq/last-contact",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
|
||||
+200
@@ -38,6 +38,87 @@ const createPerson = async (client: CoreApiClient): Promise<string> => {
|
||||
return requireId(result.createPerson?.id, 'createPerson');
|
||||
};
|
||||
|
||||
const createCompany = async (client: CoreApiClient): Promise<string> => {
|
||||
const result = await client.mutation({
|
||||
createCompany: {
|
||||
__args: { data: { name: `[test-last-contact] company ${Date.now()}` } },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return requireId(result.createCompany?.id, 'createCompany');
|
||||
};
|
||||
|
||||
const createOpportunity = async (
|
||||
client: CoreApiClient,
|
||||
{
|
||||
pointOfContactId,
|
||||
companyId,
|
||||
}: { pointOfContactId?: string; companyId?: string },
|
||||
): Promise<string> => {
|
||||
const result = await client.mutation({
|
||||
createOpportunity: {
|
||||
__args: {
|
||||
data: {
|
||||
name: `[test-last-contact] opportunity ${Date.now()}`,
|
||||
...(pointOfContactId ? { pointOfContactId } : {}),
|
||||
...(companyId ? { companyId } : {}),
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return requireId(result.createOpportunity?.id, 'createOpportunity');
|
||||
};
|
||||
|
||||
const setPersonCompany = async (
|
||||
client: CoreApiClient,
|
||||
{ personId, companyId }: { personId: string; companyId: string },
|
||||
): Promise<void> => {
|
||||
await client.mutation({
|
||||
updatePerson: {
|
||||
__args: { id: personId, data: { companyId } },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type RelatedLastContact = {
|
||||
lastContactAt: string | null;
|
||||
lastContactItemMessageId: string | null;
|
||||
lastContactItemCalendarEventId: string | null;
|
||||
};
|
||||
|
||||
const getRelatedLastContact = async (
|
||||
client: CoreApiClient,
|
||||
objectNameSingular: 'company' | 'opportunity',
|
||||
recordId: string,
|
||||
): Promise<RelatedLastContact> => {
|
||||
const result = await client.query({
|
||||
[objectNameSingular]: {
|
||||
__args: { filter: { id: { eq: recordId } } },
|
||||
id: true,
|
||||
lastContactAt: true,
|
||||
lastContactItemMessage: { id: true },
|
||||
lastContactItemCalendarEvent: { id: true },
|
||||
},
|
||||
});
|
||||
|
||||
const record = result[objectNameSingular] as {
|
||||
lastContactAt?: string | null;
|
||||
lastContactItemMessage?: { id: string } | null;
|
||||
lastContactItemCalendarEvent?: { id: string } | null;
|
||||
} | null;
|
||||
|
||||
return {
|
||||
lastContactAt: record?.lastContactAt ?? null,
|
||||
lastContactItemMessageId: record?.lastContactItemMessage?.id ?? null,
|
||||
lastContactItemCalendarEventId:
|
||||
record?.lastContactItemCalendarEvent?.id ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const createCalendarEvent = async (
|
||||
client: CoreApiClient,
|
||||
{ startsAt, isCanceled = false }: { startsAt: string; isCanceled?: boolean },
|
||||
@@ -329,6 +410,8 @@ describe('last contact handlers', () => {
|
||||
const createdMessageAssociationIds: string[] = [];
|
||||
const createdMessageIds: string[] = [];
|
||||
const createdPersonIds: string[] = [];
|
||||
const createdOpportunityIds: string[] = [];
|
||||
const createdCompanyIds: string[] = [];
|
||||
|
||||
const createLinkedMessage = async (
|
||||
receivedAt: string,
|
||||
@@ -476,12 +559,26 @@ describe('last contact handlers', () => {
|
||||
}
|
||||
createdMessageIds.length = 0;
|
||||
|
||||
for (const id of createdOpportunityIds) {
|
||||
await client
|
||||
.mutation({ destroyOpportunity: { __args: { id }, id: true } })
|
||||
.catch(() => {});
|
||||
}
|
||||
createdOpportunityIds.length = 0;
|
||||
|
||||
for (const id of createdPersonIds) {
|
||||
await client
|
||||
.mutation({ destroyPerson: { __args: { id }, id: true } })
|
||||
.catch(() => {});
|
||||
}
|
||||
createdPersonIds.length = 0;
|
||||
|
||||
for (const id of createdCompanyIds) {
|
||||
await client
|
||||
.mutation({ destroyCompany: { __args: { id }, id: true } })
|
||||
.catch(() => {});
|
||||
}
|
||||
createdCompanyIds.length = 0;
|
||||
});
|
||||
|
||||
it('should expose a lastContactAt field on people, unset by default', async () => {
|
||||
@@ -764,6 +861,109 @@ describe('last contact handlers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sets the company and opportunity last contact from a related person's email", async () => {
|
||||
const workspaceMemberId = await getWorkspaceMemberId(client);
|
||||
const companyId = await createCompany(client);
|
||||
createdCompanyIds.push(companyId);
|
||||
const personId = await createPerson(client);
|
||||
createdPersonIds.push(personId);
|
||||
await setPersonCompany(client, { personId, companyId });
|
||||
const opportunityId = await createOpportunity(client, {
|
||||
pointOfContactId: personId,
|
||||
companyId,
|
||||
});
|
||||
createdOpportunityIds.push(opportunityId);
|
||||
const receivedAt = new Date(Date.now() - 2 * DAY_IN_MS).toISOString();
|
||||
|
||||
const messageId = await recordEmail({
|
||||
personId,
|
||||
workspaceMemberId,
|
||||
receivedAt,
|
||||
direction: 'outbound',
|
||||
});
|
||||
|
||||
const companyContact = await getRelatedLastContact(
|
||||
client,
|
||||
'company',
|
||||
companyId,
|
||||
);
|
||||
expect(asTime(companyContact.lastContactAt)).toBe(asTime(receivedAt));
|
||||
expect(companyContact.lastContactItemMessageId).toBe(messageId);
|
||||
expect(companyContact.lastContactItemCalendarEventId).toBeNull();
|
||||
|
||||
const opportunityContact = await getRelatedLastContact(
|
||||
client,
|
||||
'opportunity',
|
||||
opportunityId,
|
||||
);
|
||||
expect(asTime(opportunityContact.lastContactAt)).toBe(asTime(receivedAt));
|
||||
expect(opportunityContact.lastContactItemMessageId).toBe(messageId);
|
||||
});
|
||||
|
||||
it("lets a later meeting supersede an email on the company's last contact", async () => {
|
||||
const workspaceMemberId = await getWorkspaceMemberId(client);
|
||||
const companyId = await createCompany(client);
|
||||
createdCompanyIds.push(companyId);
|
||||
const personId = await createPerson(client);
|
||||
createdPersonIds.push(personId);
|
||||
await setPersonCompany(client, { personId, companyId });
|
||||
const emailAt = new Date(Date.now() - 3 * DAY_IN_MS).toISOString();
|
||||
const meetingAt = new Date(Date.now() - 2 * DAY_IN_MS).toISOString();
|
||||
|
||||
await recordEmail({
|
||||
personId,
|
||||
workspaceMemberId,
|
||||
receivedAt: emailAt,
|
||||
direction: 'outbound',
|
||||
});
|
||||
const calendarEventId = await recordMeeting({
|
||||
personId,
|
||||
workspaceMemberId,
|
||||
startsAt: meetingAt,
|
||||
});
|
||||
|
||||
const companyContact = await getRelatedLastContact(
|
||||
client,
|
||||
'company',
|
||||
companyId,
|
||||
);
|
||||
expect(asTime(companyContact.lastContactAt)).toBe(asTime(meetingAt));
|
||||
expect(companyContact.lastContactItemCalendarEventId).toBe(calendarEventId);
|
||||
expect(companyContact.lastContactItemMessageId).toBeNull();
|
||||
});
|
||||
|
||||
it('does not overwrite a company last contact with an older interaction', async () => {
|
||||
const workspaceMemberId = await getWorkspaceMemberId(client);
|
||||
const companyId = await createCompany(client);
|
||||
createdCompanyIds.push(companyId);
|
||||
const personId = await createPerson(client);
|
||||
createdPersonIds.push(personId);
|
||||
await setPersonCompany(client, { personId, companyId });
|
||||
const newerAt = new Date(Date.now() - DAY_IN_MS).toISOString();
|
||||
const olderAt = new Date(Date.now() - 3 * DAY_IN_MS).toISOString();
|
||||
|
||||
const newerMessageId = await recordEmail({
|
||||
personId,
|
||||
workspaceMemberId,
|
||||
receivedAt: newerAt,
|
||||
direction: 'outbound',
|
||||
});
|
||||
await recordEmail({
|
||||
personId,
|
||||
workspaceMemberId,
|
||||
receivedAt: olderAt,
|
||||
direction: 'inbound',
|
||||
});
|
||||
|
||||
const companyContact = await getRelatedLastContact(
|
||||
client,
|
||||
'company',
|
||||
companyId,
|
||||
);
|
||||
expect(asTime(companyContact.lastContactAt)).toBe(asTime(newerAt));
|
||||
expect(companyContact.lastContactItemMessageId).toBe(newerMessageId);
|
||||
});
|
||||
|
||||
it('lets a later meeting supersede an earlier outbound email', async () => {
|
||||
const workspaceMemberId = await getWorkspaceMemberId(client);
|
||||
const personId = await createPerson(client);
|
||||
|
||||
+39
-1
@@ -1,6 +1,6 @@
|
||||
export const APP_DISPLAY_NAME = 'Last contact';
|
||||
export const APP_DESCRIPTION =
|
||||
'Know where every relationship stands. Adds Last contact, Last outbound, Last inbound, Last contact by, and the last email and meeting to People, kept up to date automatically from your synced emails and meetings.';
|
||||
'Know where every relationship stands. Adds Last contact, Last outbound, Last inbound, Last contact by, and the last email and meeting to People, plus Last contact on Companies and Opportunities, kept up to date automatically from your synced emails and meetings.';
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER = '66a504cc-0a75-410e-a43f-cdeae1db1522';
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = '34187abe-1b98-4153-85cd-4808e0aebf30';
|
||||
export const LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
@@ -50,3 +50,41 @@ export const LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'c8882287-f638-4a96-a235-1819e793e373';
|
||||
export const LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'257dd874-d834-403d-9cb8-f3db7e587d02';
|
||||
|
||||
export const COMPANY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'4d84b45d-128d-460f-858a-f877bf6e58ac';
|
||||
export const COMPANY_LAST_CONTACT_ITEM_MORPH_ID =
|
||||
'64a265e9-c597-4ca4-a5f2-570d9661eea7';
|
||||
export const COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'f43de098-74a9-491c-b718-2e132332c722';
|
||||
export const COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'4ef5546f-97e3-4eff-8090-91b5c6400ac9';
|
||||
export const LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'9f08c2e0-23ba-413c-94c8-04ba828586a3';
|
||||
export const LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'79103905-cfdb-413a-b964-8f347332ef3c';
|
||||
export const COMPANY_LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'08244258-dc60-475f-a2c7-3122f8b658fb';
|
||||
export const COMPANY_LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'7adc5b23-1a39-4bb6-8cff-ac4e979d38f3';
|
||||
export const COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'61a3b683-3129-4265-889e-309d7ec57403';
|
||||
|
||||
export const OPPORTUNITY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'69225d61-0efb-4959-af6d-566caba58412';
|
||||
export const OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID =
|
||||
'9199fef1-06e3-4024-8a4a-b4eee554418e';
|
||||
export const OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'0b34dd29-35e7-4082-bfd1-551513132ba1';
|
||||
export const OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'1f1645d4-d141-413b-a07d-c428240eed2f';
|
||||
export const LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'1ccff5c2-44c3-4a7d-847f-c74440fe56a9';
|
||||
export const LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'db36a44e-a6dc-4bec-a23e-3b27f63d1629';
|
||||
export const OPPORTUNITY_LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'8b447030-5124-4cfa-bd96-9836c25d4fc9';
|
||||
export const OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'cac2ec01-6c8b-4d50-b897-7717b2679842';
|
||||
export const OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'ab720bee-a0f3-41b9-9c94-8beb50e0b525';
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { COMPANY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: COMPANY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
name: 'lastContactAt',
|
||||
type: FieldType.DATE_TIME,
|
||||
label: 'Last contact',
|
||||
description:
|
||||
'When the most recent contact (email or meeting) with a person from this company occurred, in either direction.',
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
isUIEditable: false,
|
||||
});
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
OnDeleteAction,
|
||||
RelationType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
COMPANY_LAST_CONTACT_ITEM_MORPH_ID,
|
||||
LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.MORPH_RELATION,
|
||||
name: 'lastContactItemCalendarEvent',
|
||||
label: 'Last contact item',
|
||||
description:
|
||||
'The email or meeting that was the most recent contact with a person from this company.',
|
||||
icon: 'IconCalendarEvent',
|
||||
isNullable: true,
|
||||
morphId: COMPANY_LAST_CONTACT_ITEM_MORPH_ID,
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'lastContactItemCalendarEventId',
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
OnDeleteAction,
|
||||
RelationType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
COMPANY_LAST_CONTACT_ITEM_MORPH_ID,
|
||||
LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.MORPH_RELATION,
|
||||
name: 'lastContactItemMessage',
|
||||
label: 'Last contact item',
|
||||
description:
|
||||
'The email or meeting that was the most recent contact with a person from this company.',
|
||||
icon: 'IconMessage',
|
||||
isNullable: true,
|
||||
morphId: COMPANY_LAST_CONTACT_ITEM_MORPH_ID,
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'lastContactItemMessageId',
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
@@ -17,4 +17,5 @@ export default defineField({
|
||||
'When the most recent contact (email or meeting) with this person occurred, in either direction.',
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
isUIEditable: false,
|
||||
});
|
||||
|
||||
@@ -31,4 +31,5 @@ export default defineField({
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'lastContactById',
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
RelationType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
|
||||
type: FieldType.RELATION,
|
||||
name: 'lastContactForCompanies',
|
||||
label: 'Last contact for companies',
|
||||
description: 'Companies whose most recent contact was this meeting.',
|
||||
icon: 'IconBuildingSkyscraper',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
RelationType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
|
||||
type: FieldType.RELATION,
|
||||
name: 'lastContactForCompanies',
|
||||
label: 'Last contact for companies',
|
||||
description: 'Companies whose most recent contact was this email.',
|
||||
icon: 'IconBuildingSkyscraper',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
RelationType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
|
||||
type: FieldType.RELATION,
|
||||
name: 'lastContactForOpportunities',
|
||||
label: 'Last contact for opportunities',
|
||||
description: 'Opportunities whose most recent contact was this meeting.',
|
||||
icon: 'IconTargetArrow',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
RelationType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
|
||||
type: FieldType.RELATION,
|
||||
name: 'lastContactForOpportunities',
|
||||
label: 'Last contact for opportunities',
|
||||
description: 'Opportunities whose most recent contact was this email.',
|
||||
icon: 'IconTargetArrow',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
+1
@@ -28,4 +28,5 @@ export default defineField({
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
|
||||
+1
@@ -28,4 +28,5 @@ export default defineField({
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
|
||||
+1
@@ -28,4 +28,5 @@ export default defineField({
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
|
||||
+1
@@ -33,4 +33,5 @@ export default defineField({
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'lastContactItemCalendarEventId',
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
|
||||
+1
@@ -32,4 +32,5 @@ export default defineField({
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'lastContactItemMessageId',
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
|
||||
+1
@@ -27,4 +27,5 @@ export default defineField({
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
|
||||
@@ -30,4 +30,5 @@ export default defineField({
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'lastEmailId',
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
|
||||
@@ -17,4 +17,5 @@ export default defineField({
|
||||
'When this person last reached out to you (an inbound email, or a meeting they organized).',
|
||||
icon: 'IconMessageDown',
|
||||
isNullable: true,
|
||||
isUIEditable: false,
|
||||
});
|
||||
|
||||
+1
@@ -27,4 +27,5 @@ export default defineField({
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
|
||||
@@ -30,4 +30,5 @@ export default defineField({
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'lastMeetingId',
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
|
||||
@@ -17,4 +17,5 @@ export default defineField({
|
||||
'When your team last reached out to this person (an outbound email, or a meeting your team organized).',
|
||||
icon: 'IconMessageUp',
|
||||
isNullable: true,
|
||||
isUIEditable: false,
|
||||
});
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { OPPORTUNITY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: OPPORTUNITY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
name: 'lastContactAt',
|
||||
type: FieldType.DATE_TIME,
|
||||
label: 'Last contact',
|
||||
description:
|
||||
'When the most recent contact (email or meeting) with a person related to this opportunity occurred, in either direction.',
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
isUIEditable: false,
|
||||
});
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
OnDeleteAction,
|
||||
RelationType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
type: FieldType.MORPH_RELATION,
|
||||
name: 'lastContactItemCalendarEvent',
|
||||
label: 'Last contact item',
|
||||
description:
|
||||
'The email or meeting that was the most recent contact with a person related to this opportunity.',
|
||||
icon: 'IconCalendarEvent',
|
||||
isNullable: true,
|
||||
morphId: OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID,
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'lastContactItemCalendarEventId',
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
OnDeleteAction,
|
||||
RelationType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
type: FieldType.MORPH_RELATION,
|
||||
name: 'lastContactItemMessage',
|
||||
label: 'Last contact item',
|
||||
description:
|
||||
'The email or meeting that was the most recent contact with a person related to this opportunity.',
|
||||
icon: 'IconMessage',
|
||||
isNullable: true,
|
||||
morphId: OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID,
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'lastContactItemMessageId',
|
||||
},
|
||||
isUIEditable: false,
|
||||
});
|
||||
+3
-1
@@ -168,7 +168,9 @@ describe('on-calendar-event-started handler', () => {
|
||||
query.calendarEventParticipants.__args.filter.personId.eq,
|
||||
),
|
||||
).toEqual([PERSON_ID_1, PERSON_ID_2]);
|
||||
expect(mutationMock).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
mutationMock.mock.calls.filter(([mutation]) => mutation.updatePeople),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should do nothing when no event started in the time window', async () => {
|
||||
|
||||
+5
-2
@@ -64,16 +64,19 @@ describe('on-calendar-interaction handler', () => {
|
||||
.mockResolvedValueOnce({
|
||||
calendarEventParticipants: { edges: [] },
|
||||
})
|
||||
.mockResolvedValueOnce({ person: null })
|
||||
.mockResolvedValueOnce({ person: null });
|
||||
|
||||
await handler(buildEvent(PERSON_ID));
|
||||
|
||||
expect(queryMock).toHaveBeenCalledTimes(3);
|
||||
expect(queryMock).toHaveBeenCalledTimes(4);
|
||||
const queryArgs = queryMock.mock.calls[0][0];
|
||||
expect(queryArgs.calendarEventParticipants.__args.filter.personId).toEqual(
|
||||
{ eq: PERSON_ID },
|
||||
);
|
||||
expect(mutationMock).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
mutationMock.mock.calls.filter(([mutation]) => mutation.updatePeople),
|
||||
).toHaveLength(1);
|
||||
const mutationArgs = mutationMock.mock.calls[0][0];
|
||||
expect(mutationArgs.updatePeople.__args.data).toEqual({
|
||||
lastContactAt: PAST_EVENT_STARTS_AT,
|
||||
|
||||
+149
-15
@@ -17,6 +17,12 @@ type MeetingInteraction = {
|
||||
startsAt: string;
|
||||
};
|
||||
type MessageMemberInfo = { ownerId: string; fromIsMember: boolean };
|
||||
type ContactItem = { kind: 'email' | 'meeting'; id: string };
|
||||
type LastContact = { at: string; item: ContactItem };
|
||||
type OpportunityRow = {
|
||||
id: string;
|
||||
pointOfContactId: string | null;
|
||||
};
|
||||
|
||||
type PersonAgg = {
|
||||
lastContactAt?: string;
|
||||
@@ -30,6 +36,7 @@ type PersonAgg = {
|
||||
type AggByPersonId = Map<string, PersonAgg>;
|
||||
|
||||
type PersonUpdateData = Record<string, string | null>;
|
||||
type RecordUpdate = { id: string; data: PersonUpdateData };
|
||||
|
||||
const chunk = <T>(items: T[], size: number): T[][] => {
|
||||
const chunks: T[][] = [];
|
||||
@@ -237,6 +244,86 @@ const collectCalendarOwners = async (
|
||||
return ownerByCalendarEventId;
|
||||
};
|
||||
|
||||
const collectPersonCompanies = async (
|
||||
client: CoreApiClient,
|
||||
): Promise<Map<string, string>> => {
|
||||
const companyByPersonId = new Map<string, string>();
|
||||
let after: string | undefined;
|
||||
|
||||
do {
|
||||
const { people } = await client.query({
|
||||
people: {
|
||||
__args: {
|
||||
filter: { companyId: { is: 'NOT_NULL' } },
|
||||
first: PAGE_SIZE,
|
||||
after,
|
||||
},
|
||||
edges: { node: { id: true, companyId: true } },
|
||||
pageInfo: { hasNextPage: true, endCursor: true },
|
||||
},
|
||||
});
|
||||
|
||||
for (const edge of people?.edges ?? []) {
|
||||
const { id, companyId } = edge.node;
|
||||
if (id && companyId) {
|
||||
companyByPersonId.set(id, companyId);
|
||||
}
|
||||
}
|
||||
|
||||
after = people?.pageInfo.hasNextPage
|
||||
? (people.pageInfo.endCursor ?? undefined)
|
||||
: undefined;
|
||||
} while (after);
|
||||
|
||||
return companyByPersonId;
|
||||
};
|
||||
|
||||
const collectOpportunities = async (
|
||||
client: CoreApiClient,
|
||||
): Promise<OpportunityRow[]> => {
|
||||
const opportunities: OpportunityRow[] = [];
|
||||
let after: string | undefined;
|
||||
|
||||
do {
|
||||
const { opportunities: page } = await client.query({
|
||||
opportunities: {
|
||||
__args: { first: PAGE_SIZE, after },
|
||||
edges: {
|
||||
node: { id: true, pointOfContactId: true },
|
||||
},
|
||||
pageInfo: { hasNextPage: true, endCursor: true },
|
||||
},
|
||||
});
|
||||
|
||||
for (const edge of page?.edges ?? []) {
|
||||
const { id, pointOfContactId } = edge.node;
|
||||
if (id) {
|
||||
opportunities.push({
|
||||
id,
|
||||
pointOfContactId: pointOfContactId ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
after = page?.pageInfo.hasNextPage
|
||||
? (page.pageInfo.endCursor ?? undefined)
|
||||
: undefined;
|
||||
} while (after);
|
||||
|
||||
return opportunities;
|
||||
};
|
||||
|
||||
const buildRelatedData = ({ at, item }: LastContact): PersonUpdateData => ({
|
||||
lastContactAt: at,
|
||||
lastContactItemMessageId: item.kind === 'email' ? item.id : null,
|
||||
lastContactItemCalendarEventId: item.kind === 'meeting' ? item.id : null,
|
||||
});
|
||||
|
||||
const personLastContact = (agg: PersonAgg): LastContact | undefined =>
|
||||
agg.lastContactAt && agg.item
|
||||
? { at: agg.lastContactAt, item: agg.item }
|
||||
: undefined;
|
||||
|
||||
const foldEmail = (
|
||||
agg: PersonAgg,
|
||||
receivedAt: string,
|
||||
@@ -306,12 +393,33 @@ const buildData = (agg: PersonAgg): PersonUpdateData => ({
|
||||
: {}),
|
||||
});
|
||||
|
||||
const applyUpdates = async (
|
||||
client: CoreApiClient,
|
||||
mutationName: string,
|
||||
updates: RecordUpdate[],
|
||||
): Promise<void> => {
|
||||
for (const batch of chunk(updates, UPDATE_BATCH_SIZE)) {
|
||||
await Promise.all(
|
||||
batch.map(({ id, data }) =>
|
||||
client.mutation({
|
||||
[mutationName]: {
|
||||
__args: { id, data },
|
||||
id: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const [emails, meetings] = await Promise.all([
|
||||
const [emails, meetings, personCompanies, opportunities] = await Promise.all([
|
||||
collectEmailInteractions(client),
|
||||
collectMeetingInteractions(client),
|
||||
collectPersonCompanies(client),
|
||||
collectOpportunities(client),
|
||||
]);
|
||||
|
||||
const messageIds = [...new Set(emails.map((email) => email.messageId))];
|
||||
@@ -352,30 +460,56 @@ const handler = async (): Promise<void> => {
|
||||
);
|
||||
}
|
||||
|
||||
const updates = [...aggByPersonId.entries()].map(([personId, agg]) => ({
|
||||
personId,
|
||||
const personUpdates = [...aggByPersonId.entries()].map(([personId, agg]) => ({
|
||||
id: personId,
|
||||
data: buildData(agg),
|
||||
}));
|
||||
|
||||
for (const batch of chunk(updates, UPDATE_BATCH_SIZE)) {
|
||||
await Promise.all(
|
||||
batch.map(({ personId, data }) =>
|
||||
client.mutation({
|
||||
updatePerson: {
|
||||
__args: { id: personId, data },
|
||||
id: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const companyLastContact = new Map<string, LastContact>();
|
||||
for (const [personId, agg] of aggByPersonId) {
|
||||
const contact = personLastContact(agg);
|
||||
if (!contact) {
|
||||
continue;
|
||||
}
|
||||
const companyId = personCompanies.get(personId);
|
||||
if (!companyId) {
|
||||
continue;
|
||||
}
|
||||
const existing = companyLastContact.get(companyId);
|
||||
if (!existing || contact.at > existing.at) {
|
||||
companyLastContact.set(companyId, contact);
|
||||
}
|
||||
}
|
||||
|
||||
const opportunityUpdates = opportunities
|
||||
.map((opportunity): RecordUpdate | undefined => {
|
||||
const pointOfContactAgg = opportunity.pointOfContactId
|
||||
? aggByPersonId.get(opportunity.pointOfContactId)
|
||||
: undefined;
|
||||
const lastContact = pointOfContactAgg ? personLastContact(pointOfContactAgg) : undefined;
|
||||
return lastContact
|
||||
? { id: opportunity.id, data: buildRelatedData(lastContact) }
|
||||
: undefined;
|
||||
})
|
||||
.filter((update): update is RecordUpdate => Boolean(update));
|
||||
|
||||
const companyUpdates: RecordUpdate[] = [...companyLastContact.entries()].map(
|
||||
([companyId, contact]) => ({
|
||||
id: companyId,
|
||||
data: buildRelatedData(contact),
|
||||
}),
|
||||
);
|
||||
|
||||
await applyUpdates(client, 'updatePerson', personUpdates);
|
||||
await applyUpdates(client, 'updateCompany', companyUpdates);
|
||||
await applyUpdates(client, 'updateOpportunity', opportunityUpdates);
|
||||
};
|
||||
|
||||
export default definePostInstallLogicFunction({
|
||||
universalIdentifier: BACKFILL_POST_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
|
||||
name: 'backfill-last-contact',
|
||||
description:
|
||||
'Fills person last-contact fields from existing messages and calendar events after installation.',
|
||||
'Fills person, company and opportunity last-contact fields from existing messages and calendar events after installation.',
|
||||
timeoutSeconds: 300,
|
||||
shouldRunOnVersionUpgrade: true,
|
||||
handler,
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export default defineLogicFunction({
|
||||
universalIdentifier: CALENDAR_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
|
||||
name: 'on-calendar-interaction',
|
||||
description:
|
||||
"Updates a person's last-contacted fields when a new calendar event participant is created (past events only).",
|
||||
"Updates a person's last-contacted fields, and the last contact on their company and opportunities, when a new calendar event participant is created (past events only).",
|
||||
timeoutSeconds: 60,
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'calendarEventParticipant.updated',
|
||||
|
||||
+9
-1
@@ -5,6 +5,7 @@ import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { EMAIL_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { pickContactTeamMemberId } from 'src/utils/pick-contact-team-member';
|
||||
import { updatePersonForInteraction } from 'src/utils/update-person-last-contact';
|
||||
import { updateRelatedLastContact } from 'src/utils/update-related-last-contact';
|
||||
|
||||
type MessageParticipantUpdate = {
|
||||
personId?: string | null;
|
||||
@@ -69,13 +70,20 @@ const handler = async (
|
||||
workspaceMemberId,
|
||||
direction,
|
||||
});
|
||||
|
||||
await updateRelatedLastContact(client, {
|
||||
personId,
|
||||
occurredAt,
|
||||
itemId: messageId,
|
||||
kind: 'email',
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: EMAIL_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
|
||||
name: 'on-email-interaction',
|
||||
description:
|
||||
"Updates a person's last-contacted fields when a new email participant is created.",
|
||||
"Updates a person's last-contacted fields, and the last contact on their company and opportunities, when a new email participant is created.",
|
||||
timeoutSeconds: 60,
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'messageParticipant.updated',
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { updateRelatedLastContact } from 'src/utils/update-related-last-contact';
|
||||
|
||||
const PERSON_ID = '11111111-1111-1111-1111-111111111111';
|
||||
const MESSAGE_ID = '22222222-2222-2222-2222-222222222222';
|
||||
const CALENDAR_EVENT_ID = '66666666-6666-6666-6666-666666666666';
|
||||
const COMPANY_ID = '33333333-3333-3333-3333-333333333333';
|
||||
const OCCURRED_AT = '2026-06-10T09:00:00.000Z';
|
||||
|
||||
type Client = {
|
||||
query: ReturnType<typeof vi.fn>;
|
||||
mutation: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const buildClient = (): Client => ({
|
||||
query: vi.fn(),
|
||||
mutation: vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
|
||||
let client: Client;
|
||||
|
||||
beforeEach(() => {
|
||||
client = buildClient();
|
||||
});
|
||||
|
||||
describe('updateRelatedLastContact', () => {
|
||||
it('updates the company and point-of-contact opportunities for an email', async () => {
|
||||
client.query.mockResolvedValueOnce({
|
||||
person: { id: PERSON_ID, companyId: COMPANY_ID },
|
||||
});
|
||||
|
||||
await updateRelatedLastContact(client as never, {
|
||||
personId: PERSON_ID,
|
||||
occurredAt: OCCURRED_AT,
|
||||
itemId: MESSAGE_ID,
|
||||
kind: 'email',
|
||||
});
|
||||
|
||||
const companyCall = client.mutation.mock.calls.find(
|
||||
(call) => call[0].updateCompanies,
|
||||
);
|
||||
expect(companyCall?.[0].updateCompanies.__args.data).toEqual({
|
||||
lastContactAt: OCCURRED_AT,
|
||||
lastContactItemMessageId: MESSAGE_ID,
|
||||
lastContactItemCalendarEventId: null,
|
||||
});
|
||||
expect(companyCall?.[0].updateCompanies.__args.filter.and[0]).toEqual({
|
||||
id: { eq: COMPANY_ID },
|
||||
});
|
||||
|
||||
const opportunityCall = client.mutation.mock.calls.find(
|
||||
(call) => call[0].updateOpportunities,
|
||||
);
|
||||
expect(opportunityCall?.[0].updateOpportunities.__args.filter.and[0]).toEqual(
|
||||
{ pointOfContactId: { eq: PERSON_ID } },
|
||||
);
|
||||
expect(opportunityCall?.[0].updateOpportunities.__args.data).toEqual({
|
||||
lastContactAt: OCCURRED_AT,
|
||||
lastContactItemMessageId: MESSAGE_ID,
|
||||
lastContactItemCalendarEventId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the calendar event item for a meeting', async () => {
|
||||
client.query.mockResolvedValueOnce({
|
||||
person: { id: PERSON_ID, companyId: COMPANY_ID },
|
||||
});
|
||||
|
||||
await updateRelatedLastContact(client as never, {
|
||||
personId: PERSON_ID,
|
||||
occurredAt: OCCURRED_AT,
|
||||
itemId: CALENDAR_EVENT_ID,
|
||||
kind: 'meeting',
|
||||
});
|
||||
|
||||
const companyCall = client.mutation.mock.calls.find(
|
||||
(call) => call[0].updateCompanies,
|
||||
);
|
||||
expect(companyCall?.[0].updateCompanies.__args.data).toEqual({
|
||||
lastContactAt: OCCURRED_AT,
|
||||
lastContactItemMessageId: null,
|
||||
lastContactItemCalendarEventId: CALENDAR_EVENT_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('only guards against newer contacts', async () => {
|
||||
client.query.mockResolvedValueOnce({
|
||||
person: { id: PERSON_ID, companyId: COMPANY_ID },
|
||||
});
|
||||
|
||||
await updateRelatedLastContact(client as never, {
|
||||
personId: PERSON_ID,
|
||||
occurredAt: OCCURRED_AT,
|
||||
itemId: MESSAGE_ID,
|
||||
kind: 'email',
|
||||
});
|
||||
|
||||
const expectedGuard = {
|
||||
or: [
|
||||
{ lastContactAt: { is: 'NULL' } },
|
||||
{ lastContactAt: { lt: OCCURRED_AT } },
|
||||
],
|
||||
};
|
||||
const companyCall = client.mutation.mock.calls.find(
|
||||
(call) => call[0].updateCompanies,
|
||||
);
|
||||
expect(companyCall?.[0].updateCompanies.__args.filter.and[1]).toEqual(
|
||||
expectedGuard,
|
||||
);
|
||||
const opportunityCall = client.mutation.mock.calls.find(
|
||||
(call) => call[0].updateOpportunities,
|
||||
);
|
||||
expect(opportunityCall?.[0].updateOpportunities.__args.filter.and[1]).toEqual(
|
||||
expectedGuard,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the company update when the person has no company but still updates opportunities', async () => {
|
||||
client.query.mockResolvedValueOnce({
|
||||
person: { id: PERSON_ID, companyId: null },
|
||||
});
|
||||
|
||||
await updateRelatedLastContact(client as never, {
|
||||
personId: PERSON_ID,
|
||||
occurredAt: OCCURRED_AT,
|
||||
itemId: MESSAGE_ID,
|
||||
kind: 'email',
|
||||
});
|
||||
|
||||
expect(
|
||||
client.mutation.mock.calls.some((call) => call[0].updateCompanies),
|
||||
).toBe(false);
|
||||
const opportunityCall = client.mutation.mock.calls.find(
|
||||
(call) => call[0].updateOpportunities,
|
||||
);
|
||||
expect(opportunityCall?.[0].updateOpportunities.__args.filter.and[0]).toEqual(
|
||||
{ pointOfContactId: { eq: PERSON_ID } },
|
||||
);
|
||||
});
|
||||
});
|
||||
+8
@@ -2,6 +2,7 @@ import { type CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
import { pickContactTeamMemberId } from 'src/utils/pick-contact-team-member';
|
||||
import { updatePersonForInteraction } from 'src/utils/update-person-last-contact';
|
||||
import { updateRelatedLastContact } from 'src/utils/update-related-last-contact';
|
||||
|
||||
export const updatePersonLastContactFromCalendar = async (
|
||||
client: CoreApiClient,
|
||||
@@ -77,4 +78,11 @@ export const updatePersonLastContactFromCalendar = async (
|
||||
itemId: calendarEvent.id,
|
||||
workspaceMemberId,
|
||||
});
|
||||
|
||||
await updateRelatedLastContact(client, {
|
||||
personId,
|
||||
occurredAt,
|
||||
itemId: calendarEvent.id,
|
||||
kind: 'meeting',
|
||||
});
|
||||
};
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { type CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
import { type InteractionKind } from 'src/utils/update-person-last-contact';
|
||||
|
||||
export type RelatedInteraction = {
|
||||
personId: string;
|
||||
occurredAt: string;
|
||||
itemId: string;
|
||||
kind: InteractionKind;
|
||||
};
|
||||
|
||||
const recencyGuard = (occurredAt: string) => ({
|
||||
or: [
|
||||
{ lastContactAt: { is: 'NULL' } },
|
||||
{ lastContactAt: { lt: occurredAt } },
|
||||
],
|
||||
});
|
||||
|
||||
const buildData = ({
|
||||
occurredAt,
|
||||
itemId,
|
||||
kind,
|
||||
}: Omit<RelatedInteraction, 'personId'>): Record<string, string | null> => ({
|
||||
lastContactAt: occurredAt,
|
||||
lastContactItemMessageId: kind === 'email' ? itemId : null,
|
||||
lastContactItemCalendarEventId: kind === 'meeting' ? itemId : null,
|
||||
});
|
||||
|
||||
// Companies and opportunities surface emails and meetings from their related
|
||||
// people, so their last contact mirrors the most recent contact of any person
|
||||
// connected to them.
|
||||
export const updateRelatedLastContact = async (
|
||||
client: CoreApiClient,
|
||||
{ personId, occurredAt, itemId, kind }: RelatedInteraction,
|
||||
): Promise<void> => {
|
||||
const personResult = await client.query({
|
||||
person: {
|
||||
__args: { filter: { id: { eq: personId } } },
|
||||
id: true,
|
||||
companyId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const companyId =
|
||||
(personResult?.person as { companyId?: string | null } | null | undefined)
|
||||
?.companyId ?? null;
|
||||
const data = buildData({ occurredAt, itemId, kind });
|
||||
|
||||
if (companyId) {
|
||||
await client.mutation({
|
||||
updateCompanies: {
|
||||
__args: {
|
||||
data,
|
||||
filter: { and: [{ id: { eq: companyId } }, recencyGuard(occurredAt)] },
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await client.mutation({
|
||||
updateOpportunities: {
|
||||
__args: {
|
||||
data,
|
||||
filter: {
|
||||
and: [
|
||||
{ pointOfContactId: { eq: personId } },
|
||||
recencyGuard(occurredAt),
|
||||
],
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
defineViewField,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
COMPANY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
COMPANY_LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineViewField({
|
||||
universalIdentifier: COMPANY_LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.views.allCompanies
|
||||
.universalIdentifier,
|
||||
fieldMetadataUniversalIdentifier:
|
||||
COMPANY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 8,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
defineViewField,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineViewField({
|
||||
universalIdentifier:
|
||||
COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.views.allCompanies
|
||||
.universalIdentifier,
|
||||
fieldMetadataUniversalIdentifier:
|
||||
COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 10,
|
||||
isVisible: true,
|
||||
size: 180,
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
defineViewField,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
COMPANY_LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineViewField({
|
||||
universalIdentifier:
|
||||
COMPANY_LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.views.allCompanies
|
||||
.universalIdentifier,
|
||||
fieldMetadataUniversalIdentifier:
|
||||
COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 9,
|
||||
isVisible: true,
|
||||
size: 180,
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
defineViewField,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
OPPORTUNITY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
OPPORTUNITY_LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineViewField({
|
||||
universalIdentifier:
|
||||
OPPORTUNITY_LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.allOpportunities
|
||||
.universalIdentifier,
|
||||
fieldMetadataUniversalIdentifier:
|
||||
OPPORTUNITY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 7,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
defineViewField,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineViewField({
|
||||
universalIdentifier:
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.allOpportunities
|
||||
.universalIdentifier,
|
||||
fieldMetadataUniversalIdentifier:
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 9,
|
||||
isVisible: true,
|
||||
size: 180,
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
defineViewField,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineViewField({
|
||||
universalIdentifier:
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views.allOpportunities
|
||||
.universalIdentifier,
|
||||
fieldMetadataUniversalIdentifier:
|
||||
OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 8,
|
||||
isVisible: true,
|
||||
size: 180,
|
||||
});
|
||||
Reference in New Issue
Block a user