Add last contact twenty app (#21464)

Adds a last contact at column in people object
- backfill at installation
- update last contact when receiving an email or a calendar event
- cron to update last contact with recently passed calendar event

@Bonapara can you check the app logo?

<img width="909" height="464" alt="image"
src="https://github.com/user-attachments/assets/ca1c01a5-9838-4cf0-b0b8-d66a7f88b5fc"
/>

---------

Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
This commit is contained in:
martmull
2026-06-12 15:42:18 +02:00
committed by GitHub
parent bd4161a905
commit 1934fcc261
35 changed files with 5188 additions and 0 deletions
+1
View File
@@ -57,3 +57,4 @@ TRANSLATION_QA_REPORT.md
.playwright-cli/ .playwright-cli/
output/playwright/ output/playwright/
screenshots/ screenshots/
!**/screenshots/
@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
*.d.ts
@@ -0,0 +1 @@
24.5.0
@@ -0,0 +1,37 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}
@@ -0,0 +1 @@
nodeLinker: node-modules
@@ -0,0 +1,67 @@
## Base documentation
- Getting started:
- https://docs.twenty.com/developers/extend/apps/getting-started/quick-start.md
- https://docs.twenty.com/developers/extend/apps/getting-started/concepts.md
- https://docs.twenty.com/developers/extend/apps/getting-started/project-structure.md
- https://docs.twenty.com/developers/extend/apps/getting-started/local-server.md
- https://docs.twenty.com/developers/extend/apps/getting-started/scaffolding.md
- https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting.md
- Config:
- https://docs.twenty.com/developers/extend/apps/config/overview.md
- https://docs.twenty.com/developers/extend/apps/config/application.md
- https://docs.twenty.com/developers/extend/apps/config/roles.md
- https://docs.twenty.com/developers/extend/apps/config/install-hooks.md
- https://docs.twenty.com/developers/extend/apps/config/public-assets.md
- Data:
- https://docs.twenty.com/developers/extend/apps/data/overview.md
- https://docs.twenty.com/developers/extend/apps/data/objects.md
- https://docs.twenty.com/developers/extend/apps/data/extending-objects.md
- https://docs.twenty.com/developers/extend/apps/data/relations.md
- Logic:
- https://docs.twenty.com/developers/extend/apps/logic/overview.md
- https://docs.twenty.com/developers/extend/apps/logic/logic-functions.md
- https://docs.twenty.com/developers/extend/apps/logic/skills-and-agents.md
- https://docs.twenty.com/developers/extend/apps/logic/connections.md
- Layout:
- https://docs.twenty.com/developers/extend/apps/layout/overview.md
- https://docs.twenty.com/developers/extend/apps/layout/views.md
- https://docs.twenty.com/developers/extend/apps/layout/navigation-menu-items.md
- https://docs.twenty.com/developers/extend/apps/layout/page-layouts.md
- https://docs.twenty.com/developers/extend/apps/layout/front-components.md
- https://docs.twenty.com/developers/extend/apps/layout/command-menu-items.md
- Operations:
- https://docs.twenty.com/developers/extend/apps/operations/overview.md
- https://docs.twenty.com/developers/extend/apps/operations/cli.md
- https://docs.twenty.com/developers/extend/apps/operations/testing.md
- https://docs.twenty.com/developers/extend/apps/operations/publishing.md
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
This helps automatically generate required IDs etc.
@@ -0,0 +1,67 @@
## Base documentation
- Getting started:
- https://docs.twenty.com/developers/extend/apps/getting-started/quick-start.md
- https://docs.twenty.com/developers/extend/apps/getting-started/concepts.md
- https://docs.twenty.com/developers/extend/apps/getting-started/project-structure.md
- https://docs.twenty.com/developers/extend/apps/getting-started/local-server.md
- https://docs.twenty.com/developers/extend/apps/getting-started/scaffolding.md
- https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting.md
- Config:
- https://docs.twenty.com/developers/extend/apps/config/overview.md
- https://docs.twenty.com/developers/extend/apps/config/application.md
- https://docs.twenty.com/developers/extend/apps/config/roles.md
- https://docs.twenty.com/developers/extend/apps/config/install-hooks.md
- https://docs.twenty.com/developers/extend/apps/config/public-assets.md
- Data:
- https://docs.twenty.com/developers/extend/apps/data/overview.md
- https://docs.twenty.com/developers/extend/apps/data/objects.md
- https://docs.twenty.com/developers/extend/apps/data/extending-objects.md
- https://docs.twenty.com/developers/extend/apps/data/relations.md
- Logic:
- https://docs.twenty.com/developers/extend/apps/logic/overview.md
- https://docs.twenty.com/developers/extend/apps/logic/logic-functions.md
- https://docs.twenty.com/developers/extend/apps/logic/skills-and-agents.md
- https://docs.twenty.com/developers/extend/apps/logic/connections.md
- Layout:
- https://docs.twenty.com/developers/extend/apps/layout/overview.md
- https://docs.twenty.com/developers/extend/apps/layout/views.md
- https://docs.twenty.com/developers/extend/apps/layout/navigation-menu-items.md
- https://docs.twenty.com/developers/extend/apps/layout/page-layouts.md
- https://docs.twenty.com/developers/extend/apps/layout/front-components.md
- https://docs.twenty.com/developers/extend/apps/layout/command-menu-items.md
- Operations:
- https://docs.twenty.com/developers/extend/apps/operations/overview.md
- https://docs.twenty.com/developers/extend/apps/operations/cli.md
- https://docs.twenty.com/developers/extend/apps/operations/testing.md
- https://docs.twenty.com/developers/extend/apps/operations/publishing.md
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
This helps automatically generate required IDs etc.
@@ -0,0 +1,23 @@
# Last contacted at
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.
## What it does
- 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.
### Application variables
| 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. |
## Learn more
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
@@ -0,0 +1,34 @@
{
"name": "last-contacted-at",
"version": "1.0.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run --config vitest.unit.config.ts"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"twenty-sdk": "^2.10.1",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
},
"dependencies": {
"twenty-client-sdk": "^2.10.1"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,87 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -0,0 +1,377 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import onCalendarInteraction from 'src/logic-functions/on-calendar-interaction';
import onEmailInteraction from 'src/logic-functions/on-email-interaction';
const calendarHandler = onCalendarInteraction.config.handler as (
event: unknown,
) => Promise<void>;
const emailHandler = onEmailInteraction.config.handler as (
event: unknown,
) => Promise<void>;
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const requireId = (id: string | null | undefined, what: string): string => {
if (!id) throw new Error(`${what} returned no id`);
return id;
};
const asTime = (value: string | null | undefined): number | null =>
value ? new Date(value).getTime() : null;
const createPerson = async (client: CoreApiClient): Promise<string> => {
const result = await client.mutation({
createPerson: {
__args: {
data: {
name: { firstName: 'Test', lastName: `LastContact-${Date.now()}` },
},
},
id: true,
},
});
return requireId(result.createPerson?.id, 'createPerson');
};
const createCalendarEvent = async (
client: CoreApiClient,
{ startsAt, isCanceled = false }: { startsAt: string; isCanceled?: boolean },
): Promise<string> => {
const result = await client.mutation({
createCalendarEvent: {
__args: {
data: {
title: `[test-last-contact] event ${Date.now()}`,
startsAt,
isCanceled,
},
},
id: true,
},
});
return requireId(result.createCalendarEvent?.id, 'createCalendarEvent');
};
const createCalendarEventParticipant = async (
client: CoreApiClient,
{ calendarEventId, personId }: { calendarEventId: string; personId: string },
): Promise<string> => {
const result = await client.mutation({
createCalendarEventParticipant: {
__args: { data: { calendarEventId, personId } },
id: true,
},
});
return requireId(
result.createCalendarEventParticipant?.id,
'createCalendarEventParticipant',
);
};
const getAnyMessageChannelId = async (
client: CoreApiClient,
): Promise<string> => {
const result = await client.query({
messageChannelMessageAssociations: {
__args: { first: 1 },
edges: { node: { id: true, messageChannelId: true } },
},
});
const messageChannelId =
result.messageChannelMessageAssociations?.edges?.[0]?.node
?.messageChannelId;
if (!messageChannelId) {
throw new Error(
'No message channel found — run against a workspace with seeded messaging data',
);
}
return messageChannelId;
};
const createMessage = async (
client: CoreApiClient,
{ receivedAt }: { receivedAt: string },
): Promise<string> => {
const result = await client.mutation({
createMessage: {
__args: {
data: {
subject: `[test-last-contact] message ${Date.now()}`,
receivedAt,
},
},
id: true,
},
});
return requireId(result.createMessage?.id, 'createMessage');
};
const createMessageChannelAssociation = async (
client: CoreApiClient,
{ messageId, messageChannelId }: { messageId: string; messageChannelId: string },
): Promise<string> => {
const result = await client.mutation({
createMessageChannelMessageAssociation: {
__args: { data: { messageId, messageChannelId } },
id: true,
},
});
return requireId(
result.createMessageChannelMessageAssociation?.id,
'createMessageChannelMessageAssociation',
);
};
const getPersonLastContactAt = async (
client: CoreApiClient,
personId: string,
): Promise<string | null> => {
const result = await client.query({
person: {
__args: { filter: { id: { eq: personId } } },
id: true,
lastContactAt: true,
},
});
return (
(result.person as { lastContactAt?: string | null })?.lastContactAt ?? null
);
};
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const matchingApplication = result.findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(matchingApplication).toBeDefined();
});
});
describe('last contact handlers', () => {
let client: CoreApiClient;
const createdParticipantIds: string[] = [];
const createdCalendarEventIds: string[] = [];
const createdMessageAssociationIds: string[] = [];
const createdMessageIds: string[] = [];
const createdPersonIds: string[] = [];
const createLinkedMessage = async (receivedAt: string): Promise<string> => {
const messageId = await createMessage(client, { receivedAt });
createdMessageIds.push(messageId);
const messageChannelId = await getAnyMessageChannelId(client);
const associationId = await createMessageChannelAssociation(client, {
messageId,
messageChannelId,
});
createdMessageAssociationIds.push(associationId);
return messageId;
};
beforeEach(() => {
client = new CoreApiClient();
});
afterEach(async () => {
for (const id of createdParticipantIds) {
await client
.mutation({
destroyCalendarEventParticipant: { __args: { id }, id: true },
})
.catch(() => {});
}
createdParticipantIds.length = 0;
for (const id of createdCalendarEventIds) {
await client
.mutation({ destroyCalendarEvent: { __args: { id }, id: true } })
.catch(() => {});
}
createdCalendarEventIds.length = 0;
for (const id of createdMessageAssociationIds) {
await client
.mutation({
destroyMessageChannelMessageAssociation: { __args: { id }, id: true },
})
.catch(() => {});
}
createdMessageAssociationIds.length = 0;
for (const id of createdMessageIds) {
await client
.mutation({ destroyMessage: { __args: { id }, id: true } })
.catch(() => {});
}
createdMessageIds.length = 0;
for (const id of createdPersonIds) {
await client
.mutation({ destroyPerson: { __args: { id }, id: true } })
.catch(() => {});
}
createdPersonIds.length = 0;
});
it('should expose a lastContactAt field on people, unset by default', async () => {
const personId = await createPerson(client);
createdPersonIds.push(personId);
expect(await getPersonLastContactAt(client, personId)).toBeNull();
});
it('should set lastContactAt to the event startsAt when a person attended a past calendar event', async () => {
const startsAt = new Date(Date.now() - DAY_IN_MS).toISOString();
const personId = await createPerson(client);
createdPersonIds.push(personId);
const calendarEventId = await createCalendarEvent(client, { startsAt });
createdCalendarEventIds.push(calendarEventId);
const participantId = await createCalendarEventParticipant(client, {
calendarEventId,
personId,
});
createdParticipantIds.push(participantId);
await calendarHandler({
recordId: participantId,
properties: {
updatedFields: ['personId'],
after: { id: participantId, personId },
},
});
expect(asTime(await getPersonLastContactAt(client, personId))).toBe(
asTime(startsAt),
);
});
it('should not set lastContactAt when the calendar event is in the future', async () => {
const startsAt = new Date(Date.now() + DAY_IN_MS).toISOString();
const personId = await createPerson(client);
createdPersonIds.push(personId);
const calendarEventId = await createCalendarEvent(client, { startsAt });
createdCalendarEventIds.push(calendarEventId);
const participantId = await createCalendarEventParticipant(client, {
calendarEventId,
personId,
});
createdParticipantIds.push(participantId);
await calendarHandler({
recordId: participantId,
properties: {
updatedFields: ['personId'],
after: { id: participantId, personId },
},
});
expect(await getPersonLastContactAt(client, personId)).toBeNull();
});
it('should not set lastContactAt when the past calendar event is canceled', async () => {
const startsAt = new Date(Date.now() - DAY_IN_MS).toISOString();
const personId = await createPerson(client);
createdPersonIds.push(personId);
const calendarEventId = await createCalendarEvent(client, {
startsAt,
isCanceled: true,
});
createdCalendarEventIds.push(calendarEventId);
const participantId = await createCalendarEventParticipant(client, {
calendarEventId,
personId,
});
createdParticipantIds.push(participantId);
await calendarHandler({
recordId: participantId,
properties: {
updatedFields: ['personId'],
after: { id: participantId, personId },
},
});
expect(await getPersonLastContactAt(client, personId)).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);
await emailHandler({
recordId: 'unused-participant-id',
properties: {
updatedFields: ['personId'],
after: { id: 'unused-participant-id', personId, messageId },
},
});
expect(asTime(await getPersonLastContactAt(client, personId))).toBe(
asTime(receivedAt),
);
});
it('should not overwrite a newer lastContactAt with an older interaction', async () => {
const newerReceivedAt = new Date(Date.now() - DAY_IN_MS).toISOString();
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);
await emailHandler({
recordId: 'unused-participant-id',
properties: {
updatedFields: ['personId'],
after: {
id: 'unused-participant-id',
personId,
messageId: newerMessageId,
},
},
});
await emailHandler({
recordId: 'unused-participant-id',
properties: {
updatedFields: ['personId'],
after: {
id: 'unused-participant-id',
personId,
messageId: olderMessageId,
},
},
});
expect(asTime(await getPersonLastContactAt(client, personId))).toBe(
asTime(newerReceivedAt),
);
});
});
@@ -0,0 +1,28 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
CALENDAR_CRON_INTERVAL_MINUTES_VARIABLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { ABOUT_DESCRIPTION } from 'src/constants/about-description.constant';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
logoUrl: 'public/logo.png',
screenshots: ['public/gallery/cover.png'],
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
aboutDescription: ABOUT_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,21 @@
export const ABOUT_DESCRIPTION = `# Never let a relationship go cold
**Last contacted** answers the question every CRM should answer instantly: *when did we last talk to this person?*
It adds a **Last Contact** field to your People — and keeps it accurate without anyone logging anything, ever.
## Why teams install it
- **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.
## How it works
1. A **Last Contact** date field is added to your people.
2. Logic functions listen to the emails and calendar events Twenty already syncs, and keep the field set to the most recent interaction.
3. A built-in scheduler marks meetings as contact as soon as they begin.
4. A post-install backfill fills the field from your existing history.
No setup. No configuration. Install it, open People, and you immediately know who needs a follow-up.`;
@@ -0,0 +1,19 @@
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.';
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 =
'4e0fd7ff-0bbc-47b2-baab-5fe2c0d12557';
export const LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'8fc4d931-426c-413b-bd7e-0c10d1ad8d0a';
export const EMAIL_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'87c8926b-15e3-43ed-a303-60f6d072f351';
export const CALENDAR_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'ff4e9fc0-a5d3-4a3d-87f9-81c23598589e';
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';
@@ -0,0 +1,16 @@
import { defineApplicationRole } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
description: `${APP_DISPLAY_NAME} default function role`,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
});
@@ -0,0 +1,20 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
name: 'lastContactAt',
type: FieldType.DATE_TIME,
label: 'Last Contact',
description:
'When the most recent interaction (email or calendar event) with this person occurred.',
icon: 'IconClock',
isNullable: true,
});
@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(() => ({ query: queryMock, mutation: mutationMock })),
}));
import onCalendarEventStarted from '../on-calendar-event-started';
const PERSON_ID_1 = '11111111-1111-1111-1111-111111111111';
const PERSON_ID_2 = '22222222-2222-2222-2222-222222222222';
const PAST_EVENT_STARTS_AT = '2026-06-10T09:00:00.000Z';
const handler = onCalendarEventStarted.config.handler as () => Promise<void>;
type Page = {
edges: { node: Record<string, unknown> }[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
const setupQueryMock = ({
calendarEventsPages,
participantsPages,
}: {
calendarEventsPages: Page[];
participantsPages: Page[];
}) => {
const remainingEventsPages = [...calendarEventsPages];
const remainingParticipantsPages = [...participantsPages];
queryMock.mockImplementation((query) => {
if (query.calendarEvents) {
return Promise.resolve({ calendarEvents: remainingEventsPages.shift() });
}
if (query.calendarEventParticipants.__args.filter.calendarEventId) {
return Promise.resolve({
calendarEventParticipants: remainingParticipantsPages.shift(),
});
}
return Promise.resolve({
calendarEventParticipants: {
edges: [
{
node: {
id: 'participant-latest',
calendarEvent: {
id: 'event-latest',
startsAt: PAST_EVENT_STARTS_AT,
},
},
},
],
},
});
});
};
const singlePage = (nodes: Record<string, unknown>[]): Page => ({
edges: nodes.map((node) => ({ node })),
pageInfo: { hasNextPage: false, endCursor: null },
});
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({ updatePeople: [] });
});
describe('on-calendar-event-started definition', () => {
it('should be valid and run on a cron schedule', () => {
expect(onCalendarEventStarted.success).toBe(true);
expect(onCalendarEventStarted.config.cronTriggerSettings).toEqual({
pattern: '*/5 * * * *',
});
});
});
describe('on-calendar-event-started handler', () => {
it('should paginate calendar events past the query record cap', async () => {
setupQueryMock({
calendarEventsPages: [
{
edges: [{ node: { id: 'event-1' } }],
pageInfo: { hasNextPage: true, endCursor: 'events-cursor-1' },
},
singlePage([{ id: 'event-2' }]),
],
participantsPages: [singlePage([])],
});
await handler();
const calendarEventsCalls = queryMock.mock.calls.filter(
([query]) => query.calendarEvents,
);
expect(calendarEventsCalls).toHaveLength(2);
expect(calendarEventsCalls[0][0].calendarEvents.__args.first).toBe(200);
expect(calendarEventsCalls[0][0].calendarEvents.__args.after).toBeUndefined();
expect(calendarEventsCalls[1][0].calendarEvents.__args.after).toBe(
'events-cursor-1',
);
const participantsCall = queryMock.mock.calls.find(
([query]) =>
query.calendarEventParticipants?.__args.filter.calendarEventId,
);
expect(
participantsCall?.[0].calendarEventParticipants.__args.filter
.calendarEventId,
).toEqual({ in: ['event-1', 'event-2'] });
});
it('should paginate participants and update each person once', async () => {
setupQueryMock({
calendarEventsPages: [singlePage([{ id: 'event-1' }])],
participantsPages: [
{
edges: [
{ node: { id: 'participant-1', personId: PERSON_ID_1 } },
{ node: { id: 'participant-2', personId: null } },
],
pageInfo: { hasNextPage: true, endCursor: 'participants-cursor-1' },
},
singlePage([
{ id: 'participant-3', personId: PERSON_ID_1 },
{ id: 'participant-4', personId: PERSON_ID_2 },
]),
],
});
await handler();
const participantsByEventCalls = queryMock.mock.calls.filter(
([query]) =>
query.calendarEventParticipants?.__args.filter.calendarEventId,
);
expect(participantsByEventCalls).toHaveLength(2);
expect(
participantsByEventCalls[1][0].calendarEventParticipants.__args.after,
).toBe('participants-cursor-1');
const perPersonCalls = queryMock.mock.calls.filter(
([query]) => query.calendarEventParticipants?.__args.filter.personId,
);
expect(
perPersonCalls.map(
([query]) =>
query.calendarEventParticipants.__args.filter.personId.eq,
),
).toEqual([PERSON_ID_1, PERSON_ID_2]);
expect(mutationMock).toHaveBeenCalledTimes(2);
});
it('should do nothing when no event started in the time window', async () => {
setupQueryMock({
calendarEventsPages: [singlePage([])],
participantsPages: [],
});
await handler();
expect(queryMock).toHaveBeenCalledTimes(1);
expect(mutationMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(() => ({ query: queryMock, mutation: mutationMock })),
}));
import onCalendarInteraction from '../on-calendar-interaction';
const PERSON_ID = '11111111-1111-1111-1111-111111111111';
const PAST_EVENT_STARTS_AT = '2026-06-10T09:00:00.000Z';
const handler = onCalendarInteraction.config.handler as (
event: unknown,
) => Promise<void>;
const buildEvent = (personId: string | null) => ({
recordId: 'participant-1',
properties: {
updatedFields: ['personId'],
after: { id: 'participant-1', personId },
},
});
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({ updatePeople: [] });
});
describe('on-calendar-interaction definition', () => {
it('should be valid and only trigger on personId updates', () => {
expect(onCalendarInteraction.success).toBe(true);
expect(onCalendarInteraction.config.databaseEventTriggerSettings).toEqual({
eventName: 'calendarEventParticipant.updated',
updatedFields: ['personId'],
});
});
});
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,
},
},
},
],
},
});
await handler(buildEvent(PERSON_ID));
expect(queryMock).toHaveBeenCalledTimes(1);
const queryArgs = queryMock.mock.calls[0][0];
expect(queryArgs.calendarEventParticipants.__args.filter.personId).toEqual(
{ eq: PERSON_ID },
);
expect(mutationMock).toHaveBeenCalledTimes(1);
const mutationArgs = mutationMock.mock.calls[0][0];
expect(mutationArgs.updatePeople.__args.data).toEqual({
lastContactAt: PAST_EVENT_STARTS_AT,
});
});
it('should do nothing when the participant has no personId', async () => {
await handler(buildEvent(null));
expect(queryMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(() => ({ query: queryMock, mutation: mutationMock })),
}));
import onEmailInteraction from '../on-email-interaction';
const PERSON_ID = '11111111-1111-1111-1111-111111111111';
const MESSAGE_ID = '22222222-2222-2222-2222-222222222222';
const RECEIVED_AT = '2026-06-10T09:00:00.000Z';
const handler = onEmailInteraction.config.handler as (
event: unknown,
) => Promise<void>;
const buildEvent = ({
personId,
messageId,
}: {
personId: string | null;
messageId: string | null;
}) => ({
recordId: 'participant-1',
properties: {
updatedFields: ['personId'],
after: { id: 'participant-1', personId, messageId },
},
});
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({ updatePeople: [] });
});
describe('on-email-interaction definition', () => {
it('should be valid and only trigger on personId updates', () => {
expect(onEmailInteraction.success).toBe(true);
expect(onEmailInteraction.config.databaseEventTriggerSettings).toEqual({
eventName: 'messageParticipant.updated',
updatedFields: ['personId'],
});
});
});
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 },
});
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({
lastContactAt: RECEIVED_AT,
});
expect(mutationArgs.updatePeople.__args.filter.and).toContainEqual({
id: { eq: PERSON_ID },
});
});
it('should do nothing when the participant has no personId', async () => {
await handler(buildEvent({ personId: null, messageId: MESSAGE_ID }));
expect(queryMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
});
it('should do nothing when the participant has no messageId', async () => {
await handler(buildEvent({ personId: PERSON_ID, messageId: null }));
expect(queryMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
});
it('should not update the person when the message has no receivedAt', async () => {
queryMock.mockResolvedValue({
message: { id: MESSAGE_ID, receivedAt: null },
});
await handler(buildEvent({ personId: PERSON_ID, messageId: MESSAGE_ID }));
expect(mutationMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,192 @@
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,122 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
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;
const QUERY_MAX_RECORDS = 200;
const handler = async (): Promise<void> => {
const client = new CoreApiClient();
const now = new Date();
const windowStart = new Date(
now.getTime() -
(CRON_INTERVAL_MINUTES + SECURITY_OVERLAP_MINUTES) * 60 * 1000,
);
const calendarEventIds: string[] = [];
let calendarEventsCursor: string | undefined;
let calendarEventsHasNextPage = true;
while (calendarEventsHasNextPage) {
const { calendarEvents } = await client.query({
calendarEvents: {
__args: {
filter: {
and: [
{ startsAt: { gt: windowStart.toISOString() } },
{ startsAt: { lte: now.toISOString() } },
{ isCanceled: { eq: false } },
],
},
first: QUERY_MAX_RECORDS,
after: calendarEventsCursor,
},
edges: {
node: {
id: true,
},
},
pageInfo: {
hasNextPage: true,
endCursor: true,
},
},
});
calendarEventIds.push(
...(calendarEvents?.edges.map((edge) => edge.node.id) ?? []),
);
calendarEventsHasNextPage = calendarEvents?.pageInfo.hasNextPage ?? false;
calendarEventsCursor = calendarEvents?.pageInfo.endCursor ?? undefined;
}
if (calendarEventIds.length === 0) {
return;
}
const personIds = new Set<string>();
let participantsCursor: string | undefined;
let participantsHasNextPage = true;
while (participantsHasNextPage) {
const { calendarEventParticipants } = await client.query({
calendarEventParticipants: {
__args: {
filter: { calendarEventId: { in: calendarEventIds } },
first: QUERY_MAX_RECORDS,
after: participantsCursor,
},
edges: {
node: {
id: true,
personId: true,
},
},
pageInfo: {
hasNextPage: true,
endCursor: true,
},
},
});
for (const edge of calendarEventParticipants?.edges ?? []) {
const personId = edge.node.personId;
if (personId !== null && personId !== undefined) {
personIds.add(personId);
}
}
participantsHasNextPage =
calendarEventParticipants?.pageInfo.hasNextPage ?? false;
participantsCursor =
calendarEventParticipants?.pageInfo.endCursor ?? undefined;
}
await Promise.all(
[...personIds].map((personId) =>
updatePersonLastContactAtFromCalendar(client, personId),
),
);
};
export default defineLogicFunction({
universalIdentifier:
CALENDAR_EVENT_STARTED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-calendar-event-started',
description:
'Updates last-contacted fields for participants of calendar events whose start time just passed.',
timeoutSeconds: 60,
cronTriggerSettings: {
pattern: `*/${CRON_INTERVAL_MINUTES} * * * *`,
},
handler,
});
@@ -0,0 +1,33 @@
import { defineLogicFunction, ObjectRecordUpdateEvent } from 'twenty-sdk/define';
import type { DatabaseEventPayload } from 'twenty-sdk/logic-function';
import { CoreApiClient, CoreSchema } 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';
const handler = async (event: DatabaseEventPayload<
ObjectRecordUpdateEvent<CoreSchema.CalendarEventParticipant>
>): Promise<void> => {
const personId = event.properties.after.personId;
if (!personId) {
return;
}
const client = new CoreApiClient();
await updatePersonLastContactAtFromCalendar(client, personId);
};
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).',
timeoutSeconds: 60,
databaseEventTriggerSettings: {
eventName: 'calendarEventParticipant.updated',
updatedFields: ['personId'],
},
handler,
});
@@ -0,0 +1,46 @@
import { defineLogicFunction, ObjectRecordUpdateEvent } from 'twenty-sdk/define';
import type { DatabaseEventPayload } from 'twenty-sdk/logic-function';
import { CoreApiClient, CoreSchema } 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';
const handler = async (event: DatabaseEventPayload<ObjectRecordUpdateEvent<CoreSchema.MessageParticipant>>): Promise<void> => {
const personId = event.properties.after.personId;
const messageId = event.properties.after.messageId;
if (!personId || !messageId) {
return;
}
const client = new CoreApiClient();
const { message } = await client.query({
message: {
__args: { filter: { id: { eq: messageId } } },
id: true,
receivedAt: true,
},
});
const lastContactAt = message?.receivedAt ?? null;
if (!lastContactAt) {
return;
}
await updatePersonLastContactAtIfNewer(client, personId, lastContactAt);
};
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.',
timeoutSeconds: 60,
databaseEventTriggerSettings: {
eventName: 'messageParticipant.updated',
updatedFields: ['personId']
},
handler,
});
@@ -0,0 +1,99 @@
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';
const PERSON_ID = '11111111-1111-1111-1111-111111111111';
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 client = {
query: queryMock,
mutation: mutationMock,
} as unknown as CoreApiClient;
return { client, queryMock, mutationMock };
};
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(NOW));
});
afterEach(() => {
vi.useRealTimers();
});
describe('updatePersonLastContactAtFromCalendar', () => {
it('should filter on past non-canceled events in the query and only fetch the latest one', async () => {
const { client, queryMock } = buildClient({
calendarEventParticipants: { edges: [] },
});
await updatePersonLastContactAtFromCalendar(client, PERSON_ID);
expect(queryMock).toHaveBeenCalledWith({
calendarEventParticipants: {
__args: {
filter: {
personId: { eq: PERSON_ID },
calendarEvent: {
startsAt: { lte: NOW },
isCanceled: { eq: false },
},
},
orderBy: [{ calendarEvent: { startsAt: 'DescNullsLast' } }],
first: 1,
},
edges: {
node: {
id: true,
calendarEvent: {
id: true,
startsAt: true,
},
},
},
},
});
});
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,
},
},
},
],
},
});
await updatePersonLastContactAtFromCalendar(client, PERSON_ID);
expect(mutationMock).toHaveBeenCalledTimes(1);
const mutationArgs = mutationMock.mock.calls[0][0];
expect(mutationArgs.updatePeople.__args.data).toEqual({
lastContactAt: PAST_EVENT_STARTS_AT,
});
});
it('should not update the person when they have no past events', async () => {
const { client, mutationMock } = buildClient({
calendarEventParticipants: { edges: [] },
});
await updatePersonLastContactAtFromCalendar(client, PERSON_ID);
expect(mutationMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,37 @@
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,44 @@
import { 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);
};
@@ -0,0 +1,27 @@
import { 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,20 @@
import {
defineViewField,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineViewField({
universalIdentifier: LAST_CONTACT_AT_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.views.allPeople
.universalIdentifier,
fieldMetadataUniversalIdentifier: LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER,
position: 8,
isVisible: true,
size: 150,
});
@@ -0,0 +1,42 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
],
"references": [
{
"path": "./tsconfig.spec.json"
}
]
}
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
@@ -0,0 +1,31 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY =
process.env.TWENTY_API_KEY ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc';
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
},
},
});
@@ -0,0 +1,14 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
include: ['src/**/*.test.ts'],
},
});
File diff suppressed because it is too large Load Diff