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:
@@ -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'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+377
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
+21
@@ -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.`;
|
||||||
+19
@@ -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,
|
||||||
|
});
|
||||||
+170
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+82
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+100
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+192
@@ -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,
|
||||||
|
});
|
||||||
+122
@@ -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,
|
||||||
|
});
|
||||||
+33
@@ -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,
|
||||||
|
});
|
||||||
+46
@@ -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,
|
||||||
|
});
|
||||||
+99
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+37
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+44
@@ -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);
|
||||||
|
};
|
||||||
+27
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
+20
@@ -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
Reference in New Issue
Block a user