feat(last-contact): design for Last contact by + Last contact item (#22308)

To test, go to
https://twenty-applications.twenty.com/settings/applications/6aa2ca76-fdbe-456d-89ab-c622452ef055

## After

<img width="1512" height="697" alt="image"
src="https://github.com/user-attachments/assets/5b17f94e-6e0e-400a-8291-a39ad796e423"
/>


## What

Adds the design spec for the next version of the `twenty-last-contact`
public app, extending it from a single `lastContactAt` date column to
the three-column experience in the app's cover image on the All People
view:

1. **Last contact by** — the team member who last interacted with the
person (`ACTOR` field).
2. **Last contact** — the existing `lastContactAt` field, unchanged.
3. **Last contact item** — the email or meeting that was the last
contact, as a clickable record (`MORPH_RELATION` → message |
calendarEvent).

All three columns always describe the same single most-recent
interaction (atomic "newer wins" update).

This PR contains the **design doc only** —
`docs/superpowers/specs/2026-06-29-last-contact-by-and-item-design.md`.
Implementation follows.

## Why

The app today only answers *when* you last talked to someone. These
fields also answer *who* on your team and *through which* email/meeting,
matching the product vision in the cover.

## Key design decisions

- **`lastContactBy` is an ACTOR**, with the team member resolved from
the interaction's participants (`messageParticipant` /
`calendarEventParticipant` both carry `workspaceMemberId` +
`workspaceMember`).
- **No provider (Gmail/Outlook) logo.** That data lived on
`connectedAccount`, which v2.7
(`drop-connected-account-standard-object`) removed from the
app-queryable workspace schema. Confirmed acceptable; the actor still
shows the member + an email/calendar source.
- **`lastContactItem` is a MORPH_RELATION** following the SDK pattern
used by `attachment` / `noteTarget` / `taskTarget` (shared `morphId`,
one field per target, reverse relation on each target object).

## Reviewer notes

- **Load-bearing open risk** documented in the spec: how to *write* a
morph relation through the app's GraphQL API — no app in the repo writes
morph yet. The plan starts with a spike on this; if morph writes aren't
supported from an app, the fallback is two nullable `RELATION` fields
(`lastContactMessage` / `lastContactCalendarEvent`).
- No code/behavior change yet — safe to merge or hold as the design of
record.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22308?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-01 15:40:21 +02:00
committed by GitHub
parent f2e7009baa
commit 868ae4cbfd
41 changed files with 1803 additions and 442 deletions
@@ -30,6 +30,8 @@ runs:
-e NODE_PORT=2021 \
-e SERVER_URL=http://localhost:2021 \
-e MARKETPLACE_CATALOG_SYNC_CRON_ENABLED=false \
-e API_RATE_LIMITING_SHORT_LIMIT=100000 \
-e API_RATE_LIMITING_LONG_LIMIT=100000 \
twentycrm/twenty-app-dev:${{ inputs.twenty-version }}
echo "Waiting for Twenty test instance to become healthy…"
@@ -1,25 +1,24 @@
# Last contacted at
# Last Contact
A [Twenty](https://twenty.com) official application that adds a `lastContactAt` field to the standard Person object and keeps it in sync with email and calendar activity — answering the question every CRM should answer instantly: *when did we last talk to this person?*
**Always know where every relationship stands — live on your People list, straight from your synced email and calendar.**
## Why teams install it
## ✨ What you get
- **Zero manual logging** — every synced email and calendar meeting updates the field automatically, in real time.
- **Useful from minute one** — on install, your entire email and meeting history is backfilled. No empty columns, no waiting.
- **Spot cold relationships instantly** — sort or filter any People view by Last Contact to build follow-up lists in seconds.
- **Meeting-aware** — a meeting counts as contact the moment it starts, not whenever someone remembers to update the CRM.
- **Seven live columns** — 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
## What it does
## 📊 The columns
- Adds a **Last Contact** (`lastContactAt`, `DATE_TIME`) field on Person, visible in the All People view.
- Sets the field to the most recent interaction whenever a synced email or calendar event is linked to a person.
- Counts a meeting as contact when it starts, via a cron-triggered logic function.
- Backfills the field from existing message and calendar history right after install.
- **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
No setup, no configuration — install it, open People, and you immediately know who needs a follow-up.
## 💳 Billing
### Application variables
**Free** — no credits, no metering.
| Variable | Default | Description |
| --- | --- | --- |
| `CALENDAR_CRON_INTERVAL_MINUTES` | `5` | Interval between runs of `on-calendar-event-started`. The cron scans events that started within the last interval plus a 5-minute safety overlap. |
## 📌 Heads up
Needs a synced inbox or calendar (Google, Outlook, or CalDAV). Direction is inferred from the email's sender and the meeting's organizer; a meeting counts as both.
@@ -1,6 +1,6 @@
{
"name": "@twentyhq/last-contacted-at",
"version": "0.1.0",
"version": "1.0.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

After

Width:  |  Height:  |  Size: 615 KiB

@@ -60,11 +60,28 @@ const createCalendarEvent = async (
const createCalendarEventParticipant = async (
client: CoreApiClient,
{ calendarEventId, personId }: { calendarEventId: string; personId: string },
{
calendarEventId,
personId,
workspaceMemberId,
isOrganizer,
}: {
calendarEventId: string;
personId?: string;
workspaceMemberId?: string;
isOrganizer?: boolean;
},
): Promise<string> => {
const result = await client.mutation({
createCalendarEventParticipant: {
__args: { data: { calendarEventId, personId } },
__args: {
data: {
calendarEventId,
...(personId ? { personId } : {}),
...(workspaceMemberId ? { workspaceMemberId } : {}),
...(isOrganizer !== undefined ? { isOrganizer } : {}),
},
},
id: true,
},
});
@@ -75,9 +92,42 @@ const createCalendarEventParticipant = async (
);
};
let cachedWorkspaceMemberId: string | undefined;
let cachedMessageChannelId: string | undefined;
const getWorkspaceMemberId = async (
client: CoreApiClient,
): Promise<string> => {
if (cachedWorkspaceMemberId) {
return cachedWorkspaceMemberId;
}
const result = await client.query({
workspaceMembers: {
__args: { first: 1 },
edges: { node: { id: true } },
},
});
const workspaceMemberId =
result.workspaceMembers?.edges?.[0]?.node?.id;
if (!workspaceMemberId) {
throw new Error('No workspace member found in the test workspace');
}
cachedWorkspaceMemberId = workspaceMemberId;
return workspaceMemberId;
};
const getAnyMessageChannelId = async (
client: CoreApiClient,
): Promise<string> => {
if (cachedMessageChannelId) {
return cachedMessageChannelId;
}
const result = await client.query({
messageChannelMessageAssociations: {
__args: { first: 1 },
@@ -95,6 +145,8 @@ const getAnyMessageChannelId = async (
);
}
cachedMessageChannelId = messageChannelId;
return messageChannelId;
};
@@ -134,21 +186,117 @@ const createMessageChannelAssociation = async (
);
};
const getPersonLastContactAt = async (
const createMessageParticipant = async (
client: CoreApiClient,
{
messageId,
role = 'FROM',
personId,
workspaceMemberId,
}: {
messageId: string;
role?: string;
personId?: string;
workspaceMemberId?: string;
},
): Promise<string> => {
const result = await client.mutation({
createMessageParticipant: {
__args: {
data: {
messageId,
role,
...(personId ? { personId } : {}),
...(workspaceMemberId ? { workspaceMemberId } : {}),
},
},
id: true,
},
});
return requireId(
result.createMessageParticipant?.id,
'createMessageParticipant',
);
};
type PersonLastContact = {
lastContactAt: string | null;
lastContactById: string | null;
lastContactItemMessageId: string | null;
lastContactItemCalendarEventId: string | null;
lastOutboundAt: string | null;
lastInboundAt: string | null;
lastEmailId: string | null;
lastMeetingId: string | null;
};
const getPersonLastContact = async (
client: CoreApiClient,
personId: string,
): Promise<string | null> => {
): Promise<PersonLastContact> => {
const result = await client.query({
person: {
__args: { filter: { id: { eq: personId } } },
id: true,
lastContactAt: true,
lastContactById: true,
lastOutboundAt: true,
lastInboundAt: true,
lastContactItemMessage: { id: true },
lastContactItemCalendarEvent: { id: true },
lastEmail: { id: true },
lastMeeting: { id: true },
},
});
return (
(result.person as { lastContactAt?: string | null })?.lastContactAt ?? null
const person = result.person as {
lastContactAt?: string | null;
lastContactById?: string | null;
lastOutboundAt?: string | null;
lastInboundAt?: string | null;
lastContactItemMessage?: { id: string } | null;
lastContactItemCalendarEvent?: { id: string } | null;
lastEmail?: { id: string } | null;
lastMeeting?: { id: string } | null;
} | null;
return {
lastContactAt: person?.lastContactAt ?? null,
lastContactById: person?.lastContactById ?? null,
lastContactItemMessageId: person?.lastContactItemMessage?.id ?? null,
lastContactItemCalendarEventId:
person?.lastContactItemCalendarEvent?.id ?? null,
lastOutboundAt: person?.lastOutboundAt ?? null,
lastInboundAt: person?.lastInboundAt ?? null,
lastEmailId: person?.lastEmail?.id ?? null,
lastMeetingId: person?.lastMeeting?.id ?? null,
};
};
const expectColumns = (
actual: PersonLastContact,
expected: {
lastContactAt: string | null;
lastContactById: string | null;
itemMessageId: string | null;
itemCalendarEventId: string | null;
lastOutboundAt: string | null;
lastInboundAt: string | null;
lastEmailId: string | null;
lastMeetingId: string | null;
},
): void => {
expect(asTime(actual.lastContactAt)).toBe(asTime(expected.lastContactAt));
expect(actual.lastContactById).toBe(expected.lastContactById);
expect(actual.lastContactItemMessageId).toBe(expected.itemMessageId);
expect(actual.lastContactItemCalendarEventId).toBe(
expected.itemCalendarEventId,
);
expect(asTime(actual.lastOutboundAt)).toBe(asTime(expected.lastOutboundAt));
expect(asTime(actual.lastInboundAt)).toBe(asTime(expected.lastInboundAt));
expect(actual.lastEmailId).toBe(expected.lastEmailId);
expect(actual.lastMeetingId).toBe(expected.lastMeetingId);
};
describe('App installation', () => {
@@ -176,12 +324,16 @@ describe('last contact handlers', () => {
let client: CoreApiClient;
const createdParticipantIds: string[] = [];
const createdMessageParticipantIds: string[] = [];
const createdCalendarEventIds: string[] = [];
const createdMessageAssociationIds: string[] = [];
const createdMessageIds: string[] = [];
const createdPersonIds: string[] = [];
const createLinkedMessage = async (receivedAt: string): Promise<string> => {
const createLinkedMessage = async (
receivedAt: string,
personId: string,
): Promise<string> => {
const messageId = await createMessage(client, { receivedAt });
createdMessageIds.push(messageId);
const messageChannelId = await getAnyMessageChannelId(client);
@@ -190,10 +342,96 @@ describe('last contact handlers', () => {
messageChannelId,
});
createdMessageAssociationIds.push(associationId);
const participantId = await createMessageParticipant(client, {
messageId,
personId,
});
createdMessageParticipantIds.push(participantId);
return messageId;
};
const recordEmail = async ({
personId,
workspaceMemberId,
receivedAt,
direction,
}: {
personId: string;
workspaceMemberId: string;
receivedAt: string;
direction: 'outbound' | 'inbound';
}): Promise<string> => {
const messageId = await createMessage(client, { receivedAt });
createdMessageIds.push(messageId);
const sender =
direction === 'outbound' ? { workspaceMemberId } : { personId };
const recipient =
direction === 'outbound' ? { personId } : { workspaceMemberId };
createdMessageParticipantIds.push(
await createMessageParticipant(client, {
messageId,
role: 'FROM',
...sender,
}),
);
createdMessageParticipantIds.push(
await createMessageParticipant(client, {
messageId,
role: 'TO',
...recipient,
}),
);
await emailHandler({
recordId: 'unused-participant-id',
properties: {
updatedFields: ['personId'],
after: { id: 'unused-participant-id', personId, messageId },
},
});
return messageId;
};
const recordMeeting = async ({
personId,
workspaceMemberId,
startsAt,
}: {
personId: string;
workspaceMemberId: string;
startsAt: string;
}): Promise<string> => {
const calendarEventId = await createCalendarEvent(client, { startsAt });
createdCalendarEventIds.push(calendarEventId);
createdParticipantIds.push(
await createCalendarEventParticipant(client, {
calendarEventId,
personId,
}),
);
createdParticipantIds.push(
await createCalendarEventParticipant(client, {
calendarEventId,
workspaceMemberId,
isOrganizer: true,
}),
);
await calendarHandler({
recordId: 'unused-participant-id',
properties: {
updatedFields: ['personId'],
after: { id: 'unused-participant-id', personId },
},
});
return calendarEventId;
};
beforeEach(() => {
client = new CoreApiClient();
});
@@ -208,6 +446,13 @@ describe('last contact handlers', () => {
}
createdParticipantIds.length = 0;
for (const id of createdMessageParticipantIds) {
await client
.mutation({ destroyMessageParticipant: { __args: { id }, id: true } })
.catch(() => {});
}
createdMessageParticipantIds.length = 0;
for (const id of createdCalendarEventIds) {
await client
.mutation({ destroyCalendarEvent: { __args: { id }, id: true } })
@@ -243,7 +488,9 @@ describe('last contact handlers', () => {
const personId = await createPerson(client);
createdPersonIds.push(personId);
expect(await getPersonLastContactAt(client, personId)).toBeNull();
expect(
(await getPersonLastContact(client, personId)).lastContactAt,
).toBeNull();
});
it('should set lastContactAt to the event startsAt when a person attended a past calendar event', async () => {
@@ -266,9 +513,10 @@ describe('last contact handlers', () => {
},
});
expect(asTime(await getPersonLastContactAt(client, personId))).toBe(
asTime(startsAt),
);
const lastContact = await getPersonLastContact(client, personId);
expect(asTime(lastContact.lastContactAt)).toBe(asTime(startsAt));
expect(lastContact.lastContactItemCalendarEventId).toBe(calendarEventId);
expect(lastContact.lastContactItemMessageId).toBeNull();
});
it('should not set lastContactAt when the calendar event is in the future', async () => {
@@ -291,7 +539,9 @@ describe('last contact handlers', () => {
},
});
expect(await getPersonLastContactAt(client, personId)).toBeNull();
expect(
(await getPersonLastContact(client, personId)).lastContactAt,
).toBeNull();
});
it('should not set lastContactAt when the past calendar event is canceled', async () => {
@@ -317,14 +567,16 @@ describe('last contact handlers', () => {
},
});
expect(await getPersonLastContactAt(client, personId)).toBeNull();
expect(
(await getPersonLastContact(client, personId)).lastContactAt,
).toBeNull();
});
it('should set lastContactAt to the message receivedAt when a person is matched on an email', async () => {
const receivedAt = new Date(Date.now() - DAY_IN_MS).toISOString();
const personId = await createPerson(client);
createdPersonIds.push(personId);
const messageId = await createLinkedMessage(receivedAt);
const messageId = await createLinkedMessage(receivedAt, personId);
await emailHandler({
recordId: 'unused-participant-id',
@@ -334,9 +586,10 @@ describe('last contact handlers', () => {
},
});
expect(asTime(await getPersonLastContactAt(client, personId))).toBe(
asTime(receivedAt),
);
const lastContact = await getPersonLastContact(client, personId);
expect(asTime(lastContact.lastContactAt)).toBe(asTime(receivedAt));
expect(lastContact.lastContactItemMessageId).toBe(messageId);
expect(lastContact.lastContactItemCalendarEventId).toBeNull();
});
it('should not overwrite a newer lastContactAt with an older interaction', async () => {
@@ -344,8 +597,8 @@ describe('last contact handlers', () => {
const olderReceivedAt = new Date(Date.now() - 3 * DAY_IN_MS).toISOString();
const personId = await createPerson(client);
createdPersonIds.push(personId);
const newerMessageId = await createLinkedMessage(newerReceivedAt);
const olderMessageId = await createLinkedMessage(olderReceivedAt);
const newerMessageId = await createLinkedMessage(newerReceivedAt, personId);
const olderMessageId = await createLinkedMessage(olderReceivedAt, personId);
await emailHandler({
recordId: 'unused-participant-id',
@@ -370,8 +623,175 @@ describe('last contact handlers', () => {
},
});
expect(asTime(await getPersonLastContactAt(client, personId))).toBe(
asTime(newerReceivedAt),
);
expect(
asTime((await getPersonLastContact(client, personId)).lastContactAt),
).toBe(asTime(newerReceivedAt));
});
it('computes all columns for a single sent (outbound) email', async () => {
const workspaceMemberId = await getWorkspaceMemberId(client);
const personId = await createPerson(client);
createdPersonIds.push(personId);
const receivedAt = new Date(Date.now() - 5 * DAY_IN_MS).toISOString();
const messageId = await recordEmail({
personId,
workspaceMemberId,
receivedAt,
direction: 'outbound',
});
expectColumns(await getPersonLastContact(client, personId), {
lastContactAt: receivedAt,
lastContactById: workspaceMemberId,
itemMessageId: messageId,
itemCalendarEventId: null,
lastOutboundAt: receivedAt,
lastInboundAt: null,
lastEmailId: messageId,
lastMeetingId: null,
});
});
it('computes all columns for a single received (inbound) email', async () => {
const workspaceMemberId = await getWorkspaceMemberId(client);
const personId = await createPerson(client);
createdPersonIds.push(personId);
const receivedAt = new Date(Date.now() - 5 * DAY_IN_MS).toISOString();
const messageId = await recordEmail({
personId,
workspaceMemberId,
receivedAt,
direction: 'inbound',
});
expectColumns(await getPersonLastContact(client, personId), {
lastContactAt: receivedAt,
lastContactById: workspaceMemberId,
itemMessageId: messageId,
itemCalendarEventId: null,
lastOutboundAt: null,
lastInboundAt: receivedAt,
lastEmailId: messageId,
lastMeetingId: null,
});
});
it('computes all columns for a single meeting (counts as both directions)', async () => {
const workspaceMemberId = await getWorkspaceMemberId(client);
const personId = await createPerson(client);
createdPersonIds.push(personId);
const startsAt = new Date(Date.now() - 5 * DAY_IN_MS).toISOString();
const calendarEventId = await recordMeeting({
personId,
workspaceMemberId,
startsAt,
});
expectColumns(await getPersonLastContact(client, personId), {
lastContactAt: startsAt,
lastContactById: workspaceMemberId,
itemMessageId: null,
itemCalendarEventId: calendarEventId,
lastOutboundAt: startsAt,
lastInboundAt: startsAt,
lastEmailId: null,
lastMeetingId: calendarEventId,
});
});
it('computes columns through an inbound email, then a later meeting, then a later outbound email', async () => {
const workspaceMemberId = await getWorkspaceMemberId(client);
const personId = await createPerson(client);
createdPersonIds.push(personId);
const inboundAt = new Date(Date.now() - 5 * DAY_IN_MS).toISOString();
const meetingAt = new Date(Date.now() - 4 * DAY_IN_MS).toISOString();
const outboundAt = new Date(Date.now() - 3 * DAY_IN_MS).toISOString();
const inboundMessageId = await recordEmail({
personId,
workspaceMemberId,
receivedAt: inboundAt,
direction: 'inbound',
});
expectColumns(await getPersonLastContact(client, personId), {
lastContactAt: inboundAt,
lastContactById: workspaceMemberId,
itemMessageId: inboundMessageId,
itemCalendarEventId: null,
lastOutboundAt: null,
lastInboundAt: inboundAt,
lastEmailId: inboundMessageId,
lastMeetingId: null,
});
const calendarEventId = await recordMeeting({
personId,
workspaceMemberId,
startsAt: meetingAt,
});
expectColumns(await getPersonLastContact(client, personId), {
lastContactAt: meetingAt,
lastContactById: workspaceMemberId,
itemMessageId: null,
itemCalendarEventId: calendarEventId,
lastOutboundAt: meetingAt,
lastInboundAt: meetingAt,
lastEmailId: inboundMessageId,
lastMeetingId: calendarEventId,
});
const outboundMessageId = await recordEmail({
personId,
workspaceMemberId,
receivedAt: outboundAt,
direction: 'outbound',
});
expectColumns(await getPersonLastContact(client, personId), {
lastContactAt: outboundAt,
lastContactById: workspaceMemberId,
itemMessageId: outboundMessageId,
itemCalendarEventId: null,
lastOutboundAt: outboundAt,
lastInboundAt: meetingAt,
lastEmailId: outboundMessageId,
lastMeetingId: calendarEventId,
});
});
it('lets a later meeting supersede an earlier outbound email', async () => {
const workspaceMemberId = await getWorkspaceMemberId(client);
const personId = await createPerson(client);
createdPersonIds.push(personId);
const emailAt = new Date(Date.now() - 5 * DAY_IN_MS).toISOString();
const meetingAt = new Date(Date.now() - 4 * DAY_IN_MS).toISOString();
const messageId = await recordEmail({
personId,
workspaceMemberId,
receivedAt: emailAt,
direction: 'outbound',
});
const calendarEventId = await recordMeeting({
personId,
workspaceMemberId,
startsAt: meetingAt,
});
expectColumns(await getPersonLastContact(client, personId), {
lastContactAt: meetingAt,
lastContactById: workspaceMemberId,
itemMessageId: null,
itemCalendarEventId: calendarEventId,
lastOutboundAt: meetingAt,
lastInboundAt: meetingAt,
lastEmailId: messageId,
lastMeetingId: calendarEventId,
});
});
});
@@ -4,7 +4,6 @@ import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
CALENDAR_CRON_INTERVAL_MINUTES_VARIABLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
@@ -14,14 +13,4 @@ export default defineApplication({
screenshots: ['public/gallery/cover.png'],
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
applicationVariables: {
CALENDAR_CRON_INTERVAL_MINUTES: {
universalIdentifier:
CALENDAR_CRON_INTERVAL_MINUTES_VARIABLE_UNIVERSAL_IDENTIFIER,
description:
'Interval in minutes between runs of the on-calendar-event-started cron.',
value: '5',
isSecret: false,
},
},
});
@@ -0,0 +1 @@
export const CALENDAR_CRON_INTERVAL_MINUTES = 5;
@@ -0,0 +1 @@
export const CALENDAR_CRON_SECURITY_OVERLAP_MINUTES = 10;
@@ -1,6 +1,6 @@
export const APP_DISPLAY_NAME = 'Last contact';
export const APP_DESCRIPTION =
'Always know when you last talked to anyone. Adds a Last Contact field 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, 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 =
@@ -15,5 +15,38 @@ 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 CALENDAR_CRON_INTERVAL_MINUTES_VARIABLE_UNIVERSAL_IDENTIFIER =
'71cb72d1-9b5d-40fb-82d0-079495af32b1';
export const LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER =
'cfdee7bd-8d41-41e6-a888-512705e75d7b';
export const LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER =
'bec41e57-6b6f-4bb2-90cc-4030384dd8f6';
export const LAST_CONTACT_BY_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'd68d71f7-10f3-42a2-ab81-a709eee83ca7';
export const LAST_CONTACT_ITEM_MORPH_ID =
'97eafe97-0ee1-4857-b233-fec855f6bf1a';
export const LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
'e4abd1da-32f4-4025-8908-e7834e85b8e2';
export const LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
'4dea9957-ff7d-46b8-988f-cc72f187d4b5';
export const LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'cceb21fe-1422-40d4-bc8b-44c12c79c452';
export const LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'f49ce9ca-5aab-445d-9749-db256aaf74b7';
export const LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'88ab5daf-0650-4813-b9ca-f413d7c9f611';
export const LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'9c264385-e833-4d88-8149-e392651eb5dd';
export const LAST_OUTBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER =
'f4bbac22-8a3d-44c2-bbf2-7e28237252e1';
export const LAST_INBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER =
'11ee7749-735d-4fcf-b127-fa6856de7ee0';
export const LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER =
'fd705b81-c6c6-45d3-9777-818956b7627b';
export const LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
'7a3dcccb-d4fd-4839-971a-775ca7bd897f';
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';
@@ -12,9 +12,9 @@ export default defineField({
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
name: 'lastContactAt',
type: FieldType.DATE_TIME,
label: 'Last Contact',
label: 'Last contact',
description:
'When the most recent interaction (email or calendar event) with this person occurred.',
'When the most recent contact (email or meeting) with this person occurred, in either direction.',
icon: 'IconClock',
isNullable: true,
});
@@ -0,0 +1,34 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'lastContactBy',
label: 'Last contact by',
description:
'The team member whose synced email or meeting was the most recent interaction with this person.',
icon: 'IconUser',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastContactById',
},
});
@@ -0,0 +1,31 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
type: FieldType.RELATION,
name: 'lastContactForPeople',
label: 'Last contact for',
description: 'People whose most recent contact was this meeting.',
icon: 'IconUser',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,31 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
type: FieldType.RELATION,
name: 'lastContactForPeople',
label: 'Last contact for',
description: 'People whose most recent contact was this email.',
icon: 'IconUser',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,31 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
type: FieldType.RELATION,
name: 'lastContactForPeople',
label: 'Last contact for',
description: 'People whose most recent contact was through this member.',
icon: 'IconUser',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,36 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_MORPH_ID,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.MORPH_RELATION,
name: 'lastContactItemCalendarEvent',
label: 'Last contact item',
description: 'The email or meeting that was the most recent contact.',
icon: 'IconCalendarEvent',
isNullable: true,
morphId: LAST_CONTACT_ITEM_MORPH_ID,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastContactItemCalendarEventId',
},
});
@@ -0,0 +1,35 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_MORPH_ID,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.MORPH_RELATION,
name: 'lastContactItemMessage',
label: 'Last contact item',
description: 'The email or meeting that was the most recent contact.',
icon: 'IconMessage',
isNullable: true,
morphId: LAST_CONTACT_ITEM_MORPH_ID,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastContactItemMessageId',
},
});
@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
type: FieldType.RELATION,
name: 'lastEmailForPeople',
label: 'Last email for',
description: 'People whose most recent email is this one.',
icon: 'IconUser',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,33 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'lastEmail',
label: 'Last email',
description: 'The most recent email exchanged with this person.',
icon: 'IconMail',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastEmailId',
},
});
@@ -0,0 +1,20 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { LAST_INBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_INBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
name: 'lastInboundAt',
type: FieldType.DATE_TIME,
label: 'Last inbound',
description:
'When this person last reached out to you (an inbound email, or a meeting they organized).',
icon: 'IconMessageDown',
isNullable: true,
});
@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER,
LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
type: FieldType.RELATION,
name: 'lastMeetingForPeople',
label: 'Last meeting for',
description: 'People whose most recent meeting is this one.',
icon: 'IconUser',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,33 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER,
LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'lastMeeting',
label: 'Last meeting',
description: 'The most recent meeting with this person.',
icon: 'IconCalendarEvent',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastMeetingId',
},
});
@@ -0,0 +1,20 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { LAST_OUTBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_OUTBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
name: 'lastOutboundAt',
type: FieldType.DATE_TIME,
label: 'Last outbound',
description:
'When your team last reached out to this person (an outbound email, or a meeting your team organized).',
icon: 'IconMessageUp',
isNullable: true,
});
@@ -38,7 +38,19 @@ const setupQueryMock = ({
return Promise.resolve({ calendarEvents: remainingEventsPages.shift() });
}
if (query.calendarEventParticipants.__args.filter.calendarEventId) {
if (query.person) {
return Promise.resolve({ person: null });
}
const filter = query.calendarEventParticipants.__args.filter;
if (filter.calendarEventId && filter.workspaceMemberId) {
return Promise.resolve({
calendarEventParticipants: { edges: [] },
});
}
if (filter.calendarEventId) {
return Promise.resolve({
calendarEventParticipants: remainingParticipantsPages.shift(),
});
@@ -70,7 +82,7 @@ const singlePage = (nodes: Record<string, unknown>[]): Page => ({
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({ updatePeople: [] });
mutationMock.mockResolvedValue({ updatePeople: [{ id: 'updated' }] });
});
describe('on-calendar-event-started definition', () => {
@@ -139,7 +151,8 @@ describe('on-calendar-event-started handler', () => {
const participantsByEventCalls = queryMock.mock.calls.filter(
([query]) =>
query.calendarEventParticipants?.__args.filter.calendarEventId,
query.calendarEventParticipants?.__args.filter.calendarEventId &&
!query.calendarEventParticipants?.__args.filter.workspaceMemberId,
);
expect(participantsByEventCalls).toHaveLength(2);
expect(
@@ -30,7 +30,7 @@ const buildEvent = (personId: string | null) => ({
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({ updatePeople: [] });
mutationMock.mockResolvedValue({ updatePeople: [{ id: 'updated' }] });
});
describe('on-calendar-interaction definition', () => {
@@ -45,25 +45,30 @@ describe('on-calendar-interaction definition', () => {
describe('on-calendar-interaction handler', () => {
it('should update the person from the event payload without re-querying the participant', async () => {
queryMock.mockResolvedValue({
calendarEventParticipants: {
edges: [
{
node: {
id: 'participant-1',
calendarEvent: {
id: 'event-1',
startsAt: PAST_EVENT_STARTS_AT,
queryMock
.mockResolvedValueOnce({
calendarEventParticipants: {
edges: [
{
node: {
id: 'participant-1',
calendarEvent: {
id: 'event-1',
startsAt: PAST_EVENT_STARTS_AT,
},
},
},
},
],
},
});
],
},
})
.mockResolvedValueOnce({
calendarEventParticipants: { edges: [] },
})
.mockResolvedValueOnce({ person: null });
await handler(buildEvent(PERSON_ID));
expect(queryMock).toHaveBeenCalledTimes(1);
expect(queryMock).toHaveBeenCalledTimes(3);
const queryArgs = queryMock.mock.calls[0][0];
expect(queryArgs.calendarEventParticipants.__args.filter.personId).toEqual(
{ eq: PERSON_ID },
@@ -72,6 +77,12 @@ describe('on-calendar-interaction handler', () => {
const mutationArgs = mutationMock.mock.calls[0][0];
expect(mutationArgs.updatePeople.__args.data).toEqual({
lastContactAt: PAST_EVENT_STARTS_AT,
lastContactById: null,
lastContactItemCalendarEventId: 'event-1',
lastContactItemMessageId: null,
lastOutboundAt: PAST_EVENT_STARTS_AT,
lastInboundAt: PAST_EVENT_STARTS_AT,
lastMeetingId: 'event-1',
});
});
@@ -14,6 +14,7 @@ import onEmailInteraction from '../on-email-interaction';
const PERSON_ID = '11111111-1111-1111-1111-111111111111';
const MESSAGE_ID = '22222222-2222-2222-2222-222222222222';
const MEMBER_ID = '33333333-3333-3333-3333-333333333333';
const RECEIVED_AT = '2026-06-10T09:00:00.000Z';
const handler = onEmailInteraction.config.handler as (
@@ -37,7 +38,7 @@ const buildEvent = ({
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({ updatePeople: [] });
mutationMock.mockResolvedValue({ updatePeople: [{ id: 'updated' }] });
});
describe('on-email-interaction definition', () => {
@@ -51,28 +52,40 @@ describe('on-email-interaction definition', () => {
});
describe('on-email-interaction handler', () => {
it('should update the person with the triggering message receivedAt', async () => {
queryMock.mockResolvedValue({
message: { id: MESSAGE_ID, receivedAt: RECEIVED_AT },
});
it('sets interaction, owner, item, contacted and lastEmail for an outbound email', async () => {
queryMock
.mockResolvedValueOnce({
messageParticipants: {
edges: [
{
node: {
role: 'TO',
workspaceMemberId: null,
message: { receivedAt: RECEIVED_AT },
},
},
{
node: {
role: 'FROM',
workspaceMemberId: MEMBER_ID,
message: { receivedAt: RECEIVED_AT },
},
},
],
},
})
.mockResolvedValueOnce({ person: null });
await handler(buildEvent({ personId: PERSON_ID, messageId: MESSAGE_ID }));
expect(queryMock).toHaveBeenCalledTimes(1);
expect(queryMock).toHaveBeenCalledWith({
message: {
__args: { filter: { id: { eq: MESSAGE_ID } } },
id: true,
receivedAt: true,
},
});
expect(mutationMock).toHaveBeenCalledTimes(1);
const mutationArgs = mutationMock.mock.calls[0][0];
expect(mutationArgs.updatePeople.__args.data).toEqual({
const data = mutationMock.mock.calls[0][0].updatePeople.__args.data;
expect(data).toEqual({
lastContactAt: RECEIVED_AT,
});
expect(mutationArgs.updatePeople.__args.filter.and).toContainEqual({
id: { eq: PERSON_ID },
lastContactById: MEMBER_ID,
lastContactItemMessageId: MESSAGE_ID,
lastContactItemCalendarEventId: null,
lastOutboundAt: RECEIVED_AT,
lastEmailId: MESSAGE_ID,
});
});
@@ -92,7 +105,17 @@ describe('on-email-interaction handler', () => {
it('should not update the person when the message has no receivedAt', async () => {
queryMock.mockResolvedValue({
message: { id: MESSAGE_ID, receivedAt: null },
messageParticipants: {
edges: [
{
node: {
role: 'FROM',
workspaceMemberId: MEMBER_ID,
message: { receivedAt: null },
},
},
],
},
});
await handler(buildEvent({ personId: PERSON_ID, messageId: MESSAGE_ID }));
@@ -1,192 +0,0 @@
import { definePostInstallLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { BACKFILL_POST_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
const PAGE_SIZE = 200;
const UPDATE_BATCH_SIZE = 20;
type LastContactAtByPersonId = Map<string, string>;
const recordContact = (
contacts: LastContactAtByPersonId,
personId: string,
contactedAt: string,
): void => {
const current = contacts.get(personId);
if (!current || contactedAt > current) {
contacts.set(personId, contactedAt);
}
};
const chunk = <T>(items: T[], size: number): T[][] => {
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
};
const collectEmailContacts = async (
client: CoreApiClient,
contacts: LastContactAtByPersonId,
): Promise<void> => {
let after: string | undefined;
do {
const { messageParticipants } = await client.query({
messageParticipants: {
__args: {
filter: { personId: { is: 'NOT_NULL' } },
first: PAGE_SIZE,
after,
},
edges: {
node: {
id: true,
personId: true,
message: {
id: true,
receivedAt: true,
},
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
for (const edge of messageParticipants?.edges ?? []) {
const { personId, message } = edge.node;
if (personId && message?.receivedAt) {
recordContact(contacts, personId, message.receivedAt);
}
}
after = messageParticipants?.pageInfo.hasNextPage
? (messageParticipants.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
};
const collectCalendarContacts = async (
client: CoreApiClient,
contacts: LastContactAtByPersonId,
): Promise<void> => {
const now = new Date().toISOString();
let after: string | undefined;
do {
const { calendarEventParticipants } = await client.query({
calendarEventParticipants: {
__args: {
filter: { personId: { is: 'NOT_NULL' } },
first: PAGE_SIZE,
after,
},
edges: {
node: {
id: true,
personId: true,
calendarEvent: {
id: true,
startsAt: true,
isCanceled: true,
},
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
for (const edge of calendarEventParticipants?.edges ?? []) {
const { personId, calendarEvent } = edge.node;
if (
personId &&
calendarEvent?.startsAt &&
!calendarEvent.isCanceled &&
calendarEvent.startsAt <= now
) {
recordContact(contacts, personId, calendarEvent.startsAt);
}
}
after = calendarEventParticipants?.pageInfo.hasNextPage
? (calendarEventParticipants.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
};
const findPersonsToUpdate = async (
client: CoreApiClient,
contacts: LastContactAtByPersonId,
): Promise<{ personId: string; lastContactAt: string }[]> => {
const updates: { personId: string; lastContactAt: string }[] = [];
for (const personIds of chunk([...contacts.keys()], PAGE_SIZE)) {
const { people } = await client.query({
people: {
__args: {
filter: { id: { in: personIds } },
first: personIds.length,
},
edges: {
node: {
id: true,
lastContactAt: true,
},
},
},
});
for (const edge of people?.edges ?? []) {
const { id, lastContactAt: currentLastContactAt } = edge.node;
const lastContactAt = contacts.get(id);
if (
lastContactAt &&
(!currentLastContactAt || currentLastContactAt < lastContactAt)
) {
updates.push({ personId: id, lastContactAt });
}
}
}
return updates;
};
const handler = async (): Promise<void> => {
const client = new CoreApiClient();
const contacts: LastContactAtByPersonId = new Map();
await Promise.all([
collectEmailContacts(client, contacts),
collectCalendarContacts(client, contacts),
]);
const updates = await findPersonsToUpdate(client, contacts);
for (const batch of chunk(updates, UPDATE_BATCH_SIZE)) {
await Promise.all(
batch.map(({ personId, lastContactAt }) =>
client.mutation({
updatePerson: {
__args: {
id: personId,
data: { lastContactAt },
},
id: true,
},
}),
),
);
}
};
export default definePostInstallLogicFunction({
universalIdentifier: BACKFILL_POST_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'backfill-last-contact-at',
description:
'Fills person last-contacted fields from existing messages and calendar events after installation.',
timeoutSeconds: 300,
shouldRunOnVersionUpgrade: false,
handler,
});
@@ -0,0 +1,382 @@
import { definePostInstallLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { BACKFILL_POST_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
const PAGE_SIZE = 200;
const UPDATE_BATCH_SIZE = 20;
type EmailInteraction = {
personId: string;
messageId: string;
receivedAt: string;
};
type MeetingInteraction = {
personId: string;
calendarEventId: string;
startsAt: string;
};
type MessageMemberInfo = { ownerId: string; fromIsMember: boolean };
type PersonAgg = {
lastContactAt?: string;
lastContactById?: string | null;
item?: { kind: 'email' | 'meeting'; id: string };
lastOutboundAt?: string;
lastInboundAt?: string;
lastEmail?: { at: string; id: string };
lastMeeting?: { at: string; id: string };
};
type AggByPersonId = Map<string, PersonAgg>;
type PersonUpdateData = Record<string, string | null>;
const chunk = <T>(items: T[], size: number): T[][] => {
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
};
const collectEmailInteractions = async (
client: CoreApiClient,
): Promise<EmailInteraction[]> => {
const interactions: EmailInteraction[] = [];
let after: string | undefined;
do {
const { messageParticipants } = await client.query({
messageParticipants: {
__args: {
filter: { personId: { is: 'NOT_NULL' } },
first: PAGE_SIZE,
after,
},
edges: {
node: {
id: true,
personId: true,
message: { id: true, receivedAt: true },
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
for (const edge of messageParticipants?.edges ?? []) {
const { personId, message } = edge.node;
if (personId && message?.id && message?.receivedAt) {
interactions.push({
personId,
messageId: message.id,
receivedAt: message.receivedAt,
});
}
}
after = messageParticipants?.pageInfo.hasNextPage
? (messageParticipants.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
return interactions;
};
const collectMeetingInteractions = async (
client: CoreApiClient,
): Promise<MeetingInteraction[]> => {
const now = new Date().toISOString();
const interactions: MeetingInteraction[] = [];
let after: string | undefined;
do {
const { calendarEventParticipants } = await client.query({
calendarEventParticipants: {
__args: {
filter: { personId: { is: 'NOT_NULL' } },
first: PAGE_SIZE,
after,
},
edges: {
node: {
id: true,
personId: true,
calendarEvent: { id: true, startsAt: true, isCanceled: true },
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
for (const edge of calendarEventParticipants?.edges ?? []) {
const { personId, calendarEvent } = edge.node;
if (
personId &&
calendarEvent?.id &&
calendarEvent?.startsAt &&
!calendarEvent.isCanceled &&
calendarEvent.startsAt <= now
) {
interactions.push({
personId,
calendarEventId: calendarEvent.id,
startsAt: calendarEvent.startsAt,
});
}
}
after = calendarEventParticipants?.pageInfo.hasNextPage
? (calendarEventParticipants.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
return interactions;
};
const collectMessageMemberInfo = async (
client: CoreApiClient,
messageIds: string[],
): Promise<Map<string, MessageMemberInfo>> => {
const infoByMessageId = new Map<string, MessageMemberInfo>();
for (const ids of chunk(messageIds, PAGE_SIZE)) {
let after: string | undefined;
do {
const { messageParticipants } = await client.query({
messageParticipants: {
__args: {
filter: {
messageId: { in: ids },
workspaceMemberId: { is: 'NOT_NULL' },
},
first: PAGE_SIZE,
after,
},
edges: {
node: { messageId: true, role: true, workspaceMemberId: true },
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
for (const edge of messageParticipants?.edges ?? []) {
const { messageId, role, workspaceMemberId } = edge.node;
if (!messageId || !workspaceMemberId) {
continue;
}
const info = infoByMessageId.get(messageId) ?? {
ownerId: workspaceMemberId,
fromIsMember: false,
};
if (role === 'FROM') {
info.ownerId = workspaceMemberId;
info.fromIsMember = true;
}
infoByMessageId.set(messageId, info);
}
after = messageParticipants?.pageInfo.hasNextPage
? (messageParticipants.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
}
return infoByMessageId;
};
const collectCalendarOwners = async (
client: CoreApiClient,
calendarEventIds: string[],
): Promise<Map<string, string>> => {
const ownerByCalendarEventId = new Map<string, string>();
for (const ids of chunk(calendarEventIds, PAGE_SIZE)) {
let after: string | undefined;
do {
const { calendarEventParticipants } = await client.query({
calendarEventParticipants: {
__args: {
filter: {
calendarEventId: { in: ids },
workspaceMemberId: { is: 'NOT_NULL' },
},
first: PAGE_SIZE,
after,
},
edges: {
node: {
calendarEventId: true,
isOrganizer: true,
workspaceMemberId: true,
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
for (const edge of calendarEventParticipants?.edges ?? []) {
const { calendarEventId, isOrganizer, workspaceMemberId } = edge.node;
if (
calendarEventId &&
workspaceMemberId &&
(!ownerByCalendarEventId.has(calendarEventId) || isOrganizer === true)
) {
ownerByCalendarEventId.set(calendarEventId, workspaceMemberId);
}
}
after = calendarEventParticipants?.pageInfo.hasNextPage
? (calendarEventParticipants.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
}
return ownerByCalendarEventId;
};
const foldEmail = (
agg: PersonAgg,
receivedAt: string,
messageId: string,
info: MessageMemberInfo | undefined,
): void => {
if (!agg.lastEmail || receivedAt > agg.lastEmail.at) {
agg.lastEmail = { at: receivedAt, id: messageId };
}
if (info?.fromIsMember) {
if (!agg.lastOutboundAt || receivedAt > agg.lastOutboundAt) {
agg.lastOutboundAt = receivedAt;
}
} else if (!agg.lastInboundAt || receivedAt > agg.lastInboundAt) {
agg.lastInboundAt = receivedAt;
}
if (!agg.lastContactAt || receivedAt > agg.lastContactAt) {
agg.lastContactAt = receivedAt;
agg.lastContactById = info?.ownerId ?? null;
agg.item = { kind: 'email', id: messageId };
}
};
const foldMeeting = (
agg: PersonAgg,
startsAt: string,
calendarEventId: string,
ownerId: string | null,
): void => {
if (!agg.lastMeeting || startsAt > agg.lastMeeting.at) {
agg.lastMeeting = { at: startsAt, id: calendarEventId };
}
if (!agg.lastOutboundAt || startsAt > agg.lastOutboundAt) {
agg.lastOutboundAt = startsAt;
}
if (!agg.lastInboundAt || startsAt > agg.lastInboundAt) {
agg.lastInboundAt = startsAt;
}
if (!agg.lastContactAt || startsAt > agg.lastContactAt) {
agg.lastContactAt = startsAt;
agg.lastContactById = ownerId;
agg.item = { kind: 'meeting', id: calendarEventId };
}
};
const buildData = (agg: PersonAgg): PersonUpdateData => ({
...(agg.lastContactAt
? {
lastContactAt: agg.lastContactAt,
lastContactById: agg.lastContactById ?? null,
}
: {}),
...(agg.lastOutboundAt ? { lastOutboundAt: agg.lastOutboundAt } : {}),
...(agg.lastInboundAt ? { lastInboundAt: agg.lastInboundAt } : {}),
...(agg.lastEmail ? { lastEmailId: agg.lastEmail.id } : {}),
...(agg.lastMeeting ? { lastMeetingId: agg.lastMeeting.id } : {}),
...(agg.item?.kind === 'email'
? {
lastContactItemMessageId: agg.item.id,
lastContactItemCalendarEventId: null,
}
: agg.item?.kind === 'meeting'
? {
lastContactItemCalendarEventId: agg.item.id,
lastContactItemMessageId: null,
}
: {}),
});
const handler = async (): Promise<void> => {
const client = new CoreApiClient();
const [emails, meetings] = await Promise.all([
collectEmailInteractions(client),
collectMeetingInteractions(client),
]);
const messageIds = [...new Set(emails.map((email) => email.messageId))];
const calendarEventIds = [
...new Set(meetings.map((meeting) => meeting.calendarEventId)),
];
const [messageMemberInfo, calendarOwners] = await Promise.all([
collectMessageMemberInfo(client, messageIds),
collectCalendarOwners(client, calendarEventIds),
]);
const aggByPersonId: AggByPersonId = new Map();
const aggFor = (personId: string): PersonAgg => {
const existing = aggByPersonId.get(personId);
if (existing) {
return existing;
}
const created: PersonAgg = {};
aggByPersonId.set(personId, created);
return created;
};
for (const email of emails) {
foldEmail(
aggFor(email.personId),
email.receivedAt,
email.messageId,
messageMemberInfo.get(email.messageId),
);
}
for (const meeting of meetings) {
foldMeeting(
aggFor(meeting.personId),
meeting.startsAt,
meeting.calendarEventId,
calendarOwners.get(meeting.calendarEventId) ?? null,
);
}
const updates = [...aggByPersonId.entries()].map(([personId, agg]) => ({
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,
},
}),
),
);
}
};
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.',
timeoutSeconds: 300,
shouldRunOnVersionUpgrade: true,
handler,
});
@@ -1,15 +1,10 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { CALENDAR_CRON_INTERVAL_MINUTES } from 'src/constants/calendar-cron-interval-minutes';
import { CALENDAR_CRON_SECURITY_OVERLAP_MINUTES } from 'src/constants/calendar-cron-security-overlap-minutes';
import { CALENDAR_EVENT_STARTED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { updatePersonLastContactAtFromCalendar } from 'src/utils/update-person-last-contact-at-from-calendar';
const CRON_INTERVAL_MINUTES = Math.min(
Math.max(Number(process.env.CALENDAR_CRON_INTERVAL_MINUTES ?? 5), 1),
60 * 24,
);
const SECURITY_OVERLAP_MINUTES = 5;
import { updatePersonLastContactFromCalendar } from 'src/utils/update-person-last-contact-from-calendar';
const QUERY_MAX_RECORDS = 200;
@@ -19,7 +14,9 @@ const handler = async (): Promise<void> => {
const now = new Date();
const windowStart = new Date(
now.getTime() -
(CRON_INTERVAL_MINUTES + SECURITY_OVERLAP_MINUTES) * 60 * 1000,
(CALENDAR_CRON_INTERVAL_MINUTES + CALENDAR_CRON_SECURITY_OVERLAP_MINUTES) *
60 *
1000,
);
const calendarEventIds: string[] = [];
@@ -103,7 +100,7 @@ const handler = async (): Promise<void> => {
await Promise.all(
[...personIds].map((personId) =>
updatePersonLastContactAtFromCalendar(client, personId),
updatePersonLastContactFromCalendar(client, personId),
),
);
};
@@ -116,7 +113,7 @@ export default defineLogicFunction({
'Updates last-contacted fields for participants of calendar events whose start time just passed.',
timeoutSeconds: 60,
cronTriggerSettings: {
pattern: `*/${CRON_INTERVAL_MINUTES} * * * *`,
pattern: `*/${CALENDAR_CRON_INTERVAL_MINUTES} * * * *`,
},
handler,
});
@@ -3,7 +3,7 @@ import { type DatabaseEventPayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { CALENDAR_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { updatePersonLastContactAtFromCalendar } from 'src/utils/update-person-last-contact-at-from-calendar';
import { updatePersonLastContactFromCalendar } from 'src/utils/update-person-last-contact-from-calendar';
type CalendarEventParticipantUpdate = {
personId?: string | null;
@@ -22,7 +22,7 @@ const handler = async (
const client = new CoreApiClient();
await updatePersonLastContactAtFromCalendar(client, personId);
await updatePersonLastContactFromCalendar(client, personId);
};
export default defineLogicFunction({
@@ -3,14 +3,17 @@ import type { DatabaseEventPayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { EMAIL_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { updatePersonLastContactAtIfNewer } from 'src/utils/update-person-last-contact-at';
import { pickContactTeamMemberId } from 'src/utils/pick-contact-team-member';
import { updatePersonForInteraction } from 'src/utils/update-person-last-contact';
type MessageParticipantUpdate = {
personId?: string | null;
messageId?: string | null;
};
const handler = async (event: DatabaseEventPayload<ObjectRecordUpdateEvent<MessageParticipantUpdate>>): Promise<void> => {
const handler = async (
event: DatabaseEventPayload<ObjectRecordUpdateEvent<MessageParticipantUpdate>>,
): Promise<void> => {
const personId = event.properties.after.personId;
const messageId = event.properties.after.messageId;
@@ -20,32 +23,63 @@ const handler = async (event: DatabaseEventPayload<ObjectRecordUpdateEvent<Messa
const client = new CoreApiClient();
const { message } = await client.query({
message: {
__args: { filter: { id: { eq: messageId } } },
id: true,
receivedAt: true,
const { messageParticipants } = await client.query({
messageParticipants: {
__args: { filter: { messageId: { eq: messageId } }, first: 200 },
edges: {
node: {
role: true,
workspaceMemberId: true,
message: { receivedAt: true },
},
},
},
});
const lastContactAt = message?.receivedAt ?? null;
const participants =
messageParticipants?.edges?.map(
(edge: {
node: {
role: string | null;
workspaceMemberId: string | null;
message: { receivedAt: string | null } | null;
};
}) => edge.node,
) ?? [];
const occurredAt = participants[0]?.message?.receivedAt ?? null;
if (!lastContactAt) {
if (!occurredAt) {
return;
}
await updatePersonLastContactAtIfNewer(client, personId, lastContactAt);
const fromParticipant = participants.find(
(participant: { role: string | null; workspaceMemberId: string | null }) =>
participant.role === 'FROM',
);
const direction = fromParticipant?.workspaceMemberId ? 'outbound' : 'inbound';
const workspaceMemberId = pickContactTeamMemberId(participants, {
role: 'FROM',
});
await updatePersonForInteraction(client, {
personId,
occurredAt,
kind: 'email',
itemId: messageId,
workspaceMemberId,
direction,
});
};
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 when a new email participant is created.",
timeoutSeconds: 60,
databaseEventTriggerSettings: {
eventName: 'messageParticipant.updated',
updatedFields: ['personId']
updatedFields: ['personId'],
},
handler,
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { pickContactTeamMemberId } from 'src/utils/pick-contact-team-member';
const MEMBER_A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const MEMBER_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
describe('pickContactTeamMemberId', () => {
it('prefers the from participant for email', () => {
const result = pickContactTeamMemberId(
[
{ role: 'TO', workspaceMemberId: MEMBER_A },
{ role: 'FROM', workspaceMemberId: MEMBER_B },
],
{ role: 'FROM' },
);
expect(result).toBe(MEMBER_B);
});
it('falls back to the first member when no from member exists', () => {
const result = pickContactTeamMemberId(
[
{ role: 'TO', workspaceMemberId: null },
{ role: 'CC', workspaceMemberId: MEMBER_A },
],
{ role: 'FROM' },
);
expect(result).toBe(MEMBER_A);
});
it('prefers the organizer for calendar', () => {
const result = pickContactTeamMemberId(
[
{ isOrganizer: false, workspaceMemberId: MEMBER_A },
{ isOrganizer: true, workspaceMemberId: MEMBER_B },
],
{ isOrganizer: true },
);
expect(result).toBe(MEMBER_B);
});
it('returns null when no participant is a workspace member', () => {
const result = pickContactTeamMemberId(
[{ role: 'TO', workspaceMemberId: null }],
{ role: 'FROM' },
);
expect(result).toBeNull();
});
});
@@ -1,15 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CoreApiClient } from 'twenty-client-sdk/core';
import { updatePersonLastContactAtFromCalendar } from 'src/utils/update-person-last-contact-at-from-calendar';
import { updatePersonLastContactFromCalendar } from 'src/utils/update-person-last-contact-from-calendar';
const PERSON_ID = '11111111-1111-1111-1111-111111111111';
const MEMBER_ID = '33333333-3333-3333-3333-333333333333';
const CALENDAR_EVENT_ID = '44444444-4444-4444-4444-444444444444';
const NOW = '2026-06-12T12:00:00.000Z';
const PAST_EVENT_STARTS_AT = '2026-06-10T09:00:00.000Z';
const buildClient = (queryResult: unknown) => {
const queryMock = vi.fn().mockResolvedValue(queryResult);
const mutationMock = vi.fn().mockResolvedValue({ updatePeople: [] });
const buildClient = (...queryResults: unknown[]) => {
const queryMock = vi.fn();
for (const queryResult of queryResults) {
queryMock.mockResolvedValueOnce(queryResult);
}
const mutationMock = vi.fn().mockResolvedValue({ updatePeople: [{ id: 'updated' }] });
const client = {
query: queryMock,
mutation: mutationMock,
@@ -28,12 +33,12 @@ afterEach(() => {
});
describe('updatePersonLastContactAtFromCalendar', () => {
it('should filter on past non-canceled events in the query and only fetch the latest one', async () => {
it('should query the latest past non-canceled event for the person', async () => {
const { client, queryMock } = buildClient({
calendarEventParticipants: { edges: [] },
});
await updatePersonLastContactAtFromCalendar(client, PERSON_ID);
await updatePersonLastContactFromCalendar(client, PERSON_ID);
expect(queryMock).toHaveBeenCalledWith({
calendarEventParticipants: {
@@ -61,29 +66,68 @@ describe('updatePersonLastContactAtFromCalendar', () => {
});
});
it('should update the person with the latest past event startsAt', async () => {
const { client, mutationMock } = buildClient({
calendarEventParticipants: {
edges: [
{
node: {
id: 'participant-1',
calendarEvent: {
id: 'event-1',
startsAt: PAST_EVENT_STARTS_AT,
it('sets lastContactAt, organizer member and the calendarEvent item', async () => {
const { client, queryMock, mutationMock } = buildClient(
{
calendarEventParticipants: {
edges: [
{
node: {
id: 'participant-1',
calendarEvent: {
id: CALENDAR_EVENT_ID,
startsAt: PAST_EVENT_STARTS_AT,
},
},
},
],
},
},
{
calendarEventParticipants: {
edges: [
{
node: {
isOrganizer: false,
workspaceMemberId: '55555555-5555-5555-5555-555555555555',
},
},
{ node: { isOrganizer: true, workspaceMemberId: MEMBER_ID } },
],
},
},
{ person: null },
);
await updatePersonLastContactFromCalendar(client, PERSON_ID);
expect(queryMock).toHaveBeenNthCalledWith(2, {
calendarEventParticipants: {
__args: {
filter: {
calendarEventId: { eq: CALENDAR_EVENT_ID },
workspaceMemberId: { is: 'NOT_NULL' },
},
],
first: 200,
},
edges: {
node: {
isOrganizer: true,
workspaceMemberId: true,
},
},
},
});
await updatePersonLastContactAtFromCalendar(client, PERSON_ID);
expect(mutationMock).toHaveBeenCalledTimes(1);
const mutationArgs = mutationMock.mock.calls[0][0];
expect(mutationArgs.updatePeople.__args.data).toEqual({
const data = mutationMock.mock.calls[0][0].updatePeople.__args.data;
expect(data).toEqual({
lastContactAt: PAST_EVENT_STARTS_AT,
lastContactById: MEMBER_ID,
lastContactItemCalendarEventId: CALENDAR_EVENT_ID,
lastContactItemMessageId: null,
lastOutboundAt: PAST_EVENT_STARTS_AT,
lastInboundAt: PAST_EVENT_STARTS_AT,
lastMeetingId: CALENDAR_EVENT_ID,
});
});
@@ -92,7 +136,7 @@ describe('updatePersonLastContactAtFromCalendar', () => {
calendarEventParticipants: { edges: [] },
});
await updatePersonLastContactAtFromCalendar(client, PERSON_ID);
await updatePersonLastContactFromCalendar(client, PERSON_ID);
expect(mutationMock).not.toHaveBeenCalled();
});
@@ -1,37 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { CoreApiClient } from 'twenty-client-sdk/core';
import { updatePersonLastContactAtIfNewer } from 'src/utils/update-person-last-contact-at';
const PERSON_ID = '11111111-1111-1111-1111-111111111111';
const LAST_CONTACT_AT = '2026-06-01T10:00:00.000Z';
describe('updatePersonLastContactAtIfNewer', () => {
it('should update lastContactAt only when the value is newer or unset', async () => {
const mutationMock = vi.fn().mockResolvedValue({ updatePeople: [] });
const client = { mutation: mutationMock } as unknown as CoreApiClient;
await updatePersonLastContactAtIfNewer(client, PERSON_ID, LAST_CONTACT_AT);
expect(mutationMock).toHaveBeenCalledTimes(1);
expect(mutationMock).toHaveBeenCalledWith({
updatePeople: {
__args: {
data: { lastContactAt: LAST_CONTACT_AT },
filter: {
and: [
{ id: { eq: PERSON_ID } },
{
or: [
{ lastContactAt: { is: 'NULL' } },
{ lastContactAt: { lt: LAST_CONTACT_AT } },
],
},
],
},
},
id: true,
},
});
});
});
@@ -0,0 +1,26 @@
export type Participant = {
workspaceMemberId?: string | null;
role?: string | null;
isOrganizer?: boolean | null;
};
export const pickContactTeamMemberId = (
participants: Participant[],
prefer: { role: 'FROM' } | { isOrganizer: true },
): string | null => {
const members = participants.filter((participant) =>
Boolean(participant.workspaceMemberId),
);
if (members.length === 0) {
return null;
}
const preferred = members.find((participant) =>
'role' in prefer
? participant.role === prefer.role
: participant.isOrganizer === true,
);
return (preferred ?? members[0]).workspaceMemberId ?? null;
};
@@ -1,44 +0,0 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { updatePersonLastContactAtIfNewer } from 'src/utils/update-person-last-contact-at';
export const updatePersonLastContactAtFromCalendar = async (
client: CoreApiClient,
personId: string,
): Promise<void> => {
const now = new Date().toISOString();
const { calendarEventParticipants } = await client.query({
calendarEventParticipants: {
__args: {
filter: {
personId: { eq: personId },
calendarEvent: {
startsAt: { lte: now },
isCanceled: { eq: false },
},
},
orderBy: [{ calendarEvent: { startsAt: 'DescNullsLast' } }],
first: 1,
},
edges: {
node: {
id: true,
calendarEvent: {
id: true,
startsAt: true,
},
},
},
},
});
const lastContactAt =
calendarEventParticipants?.edges[0]?.node?.calendarEvent?.startsAt ?? null;
if (!lastContactAt) {
return;
}
await updatePersonLastContactAtIfNewer(client, personId, lastContactAt);
};
@@ -1,27 +0,0 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
export const updatePersonLastContactAtIfNewer = async (
client: CoreApiClient,
personId: string,
lastContactAt: string,
): Promise<void> => {
await client.mutation({
updatePeople: {
__args: {
data: { lastContactAt },
filter: {
and: [
{ id: { eq: personId } },
{
or: [
{ lastContactAt: { is: 'NULL' } },
{ lastContactAt: { lt: lastContactAt } },
],
},
],
},
},
id: true,
},
});
};
@@ -0,0 +1,80 @@
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';
export const updatePersonLastContactFromCalendar = async (
client: CoreApiClient,
personId: string,
): Promise<void> => {
const now = new Date().toISOString();
const { calendarEventParticipants } = await client.query({
calendarEventParticipants: {
__args: {
filter: {
personId: { eq: personId },
calendarEvent: {
startsAt: { lte: now },
isCanceled: { eq: false },
},
},
orderBy: [{ calendarEvent: { startsAt: 'DescNullsLast' } }],
first: 1,
},
edges: {
node: {
id: true,
calendarEvent: {
id: true,
startsAt: true,
},
},
},
},
});
const calendarEvent =
calendarEventParticipants?.edges[0]?.node?.calendarEvent ?? null;
const occurredAt = calendarEvent?.startsAt ?? null;
if (!calendarEvent?.id || !occurredAt) {
return;
}
const { calendarEventParticipants: memberParticipants } = await client.query({
calendarEventParticipants: {
__args: {
filter: {
calendarEventId: { eq: calendarEvent.id },
workspaceMemberId: { is: 'NOT_NULL' },
},
first: 200,
},
edges: {
node: {
isOrganizer: true,
workspaceMemberId: true,
},
},
},
});
const participants =
memberParticipants?.edges?.map(
(edge: {
node: { isOrganizer: boolean | null; workspaceMemberId: string | null };
}) => edge.node,
) ?? [];
const workspaceMemberId = pickContactTeamMemberId(participants, {
isOrganizer: true,
});
await updatePersonForInteraction(client, {
personId,
occurredAt,
kind: 'meeting',
itemId: calendarEvent.id,
workspaceMemberId,
});
};
@@ -0,0 +1,130 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
export type InteractionKind = 'email' | 'meeting';
export type InteractionDirection = 'outbound' | 'inbound';
export type Interaction = {
personId: string;
occurredAt: string;
itemId: string;
workspaceMemberId: string | null;
} & ({ kind: 'email'; direction: InteractionDirection } | { kind: 'meeting' });
const isNewer = (
candidate: string,
current: string | null | undefined,
): boolean => !current || current < candidate;
export const updatePersonForInteraction = async (
client: CoreApiClient,
interaction: Interaction,
): Promise<void> => {
const { personId, occurredAt, kind, itemId, workspaceMemberId } = interaction;
const { person } = await client.query({
person: {
__args: { filter: { id: { eq: personId } } },
id: true,
lastContactAt: true,
lastOutboundAt: true,
lastInboundAt: true,
lastEmail: { receivedAt: true },
lastMeeting: { startsAt: true },
},
});
const current = (person ?? {}) as {
lastContactAt?: string | null;
lastOutboundAt?: string | null;
lastInboundAt?: string | null;
lastEmail?: { receivedAt: string | null } | null;
lastMeeting?: { startsAt: string | null } | null;
};
const data: Record<string, string | null> = {};
if (isNewer(occurredAt, current.lastContactAt)) {
data.lastContactAt = occurredAt;
data.lastContactById = workspaceMemberId ?? null;
if (kind === 'email') {
data.lastContactItemMessageId = itemId;
data.lastContactItemCalendarEventId = null;
} else {
data.lastContactItemCalendarEventId = itemId;
data.lastContactItemMessageId = null;
}
}
const touchesOutbound =
interaction.kind === 'meeting' || interaction.direction === 'outbound';
const touchesInbound =
interaction.kind === 'meeting' || interaction.direction === 'inbound';
if (touchesOutbound && isNewer(occurredAt, current.lastOutboundAt)) {
data.lastOutboundAt = occurredAt;
}
if (touchesInbound && isNewer(occurredAt, current.lastInboundAt)) {
data.lastInboundAt = occurredAt;
}
if (kind === 'email' && isNewer(occurredAt, current.lastEmail?.receivedAt)) {
data.lastEmailId = itemId;
}
if (kind === 'meeting' && isNewer(occurredAt, current.lastMeeting?.startsAt)) {
data.lastMeetingId = itemId;
}
if (Object.keys(data).length === 0) {
return;
}
if ('lastContactAt' in data) {
const { updatePeople } = await client.mutation({
updatePeople: {
__args: {
data,
filter: {
and: [
{ id: { eq: personId } },
{
or: [
{ lastContactAt: { is: 'NULL' } },
{ lastContactAt: { lt: occurredAt } },
],
},
],
},
},
id: true,
},
});
if (Array.isArray(updatePeople) && updatePeople.length > 0) {
return;
}
const directionalData: Record<string, string | null> = { ...data };
delete directionalData.lastContactAt;
delete directionalData.lastContactById;
delete directionalData.lastContactItemMessageId;
delete directionalData.lastContactItemCalendarEventId;
if (Object.keys(directionalData).length === 0) {
return;
}
await client.mutation({
updatePerson: {
__args: { id: personId, data: directionalData },
id: true,
},
});
return;
}
await client.mutation({
updatePerson: {
__args: { id: personId, data },
id: true,
},
});
};
@@ -0,0 +1,20 @@
import {
defineViewField,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_BY_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineViewField({
universalIdentifier: LAST_CONTACT_BY_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.views.allPeople
.universalIdentifier,
fieldMetadataUniversalIdentifier: LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER,
position: 7,
isVisible: true,
size: 150,
});
@@ -0,0 +1,22 @@
import {
defineViewField,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineViewField({
universalIdentifier:
LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.views.allPeople
.universalIdentifier,
fieldMetadataUniversalIdentifier:
LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
position: 10,
isVisible: true,
size: 180,
});
@@ -0,0 +1,21 @@
import {
defineViewField,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineViewField({
universalIdentifier: LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.views.allPeople
.universalIdentifier,
fieldMetadataUniversalIdentifier:
LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
position: 9,
isVisible: true,
size: 180,
});