snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 3s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# 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

View File

@@ -0,0 +1 @@
24.5.0

View File

@@ -0,0 +1,58 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.base.json"],
"plugins": ["react", "typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
],
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/jsx-key": "off",
"react/display-name": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-useless-fragment": "off",
"react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -0,0 +1,66 @@
## 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 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.

View File

@@ -0,0 +1,38 @@
# Changelog
## 1.3.0
- Run every database-event trigger in batch mode. A mailbox sync emits one `messageParticipant.updated` event per synced message, which used to enqueue one job per event and flood the workers; a batch now arrives as a single job. Each handler folds its batch down to the distinct records it has to touch (one update per person, company or opportunity, not one per event). Messages, meetings and opportunities are then resolved with `in` queries over the whole batch. The company recompute still runs one indexed lookup per company: finding a company's most recently contacted person cannot be batched without scanning all of its people.
- Resolve a calendar interaction from the participant's own calendar event instead of re-querying the person's most recent past meeting. Participants linked to a future event are left to the `on-calendar-event-started` cron, as before.
- Require Twenty `>=2.40.0`: batch mode for database event triggers only exists from 2.40.
## 1.2.4
- Limit the app role to reading synced messages, calendar events, and their participants, and to reading and updating people, companies, and opportunities. The app no longer requests read and edit access to every record type.
## 1.2.3
- Rework the last-contact backfill into a single fan-out instead of a logic function that called its own HTTP route in a loop with blocking sleeps. On install it counts people, opportunities and companies and enqueues one job per record batch via `enqueueJob`. Each job receives its batch id and processes the matching record window (offset pagination). Jobs are staggered with `delayMs` to stay under the hosted API rate limiting.
## 1.2.0
- Compute last contact on Companies and Opportunities when the record or its relationships change, not only on new interactions: opportunities recompute from their point of contact on creation and when it changes, and companies recompute from their people on creation and when a person joins or leaves.
- Rework the last-contact backfill into a sequential, cursor-paginated process orchestrated through the kv-store (people, then opportunities, then companies). Each run handles one batch and hands the next cursor back to the orchestrator, which pauses between runs to stay under the hosted API rate limiting. Batch size and pause are server variables.
## 1.1.3
- Stop declaring INDEX view fields explicitly: the server now provisions the INDEX view column for each app field automatically, so the manifest no longer targets the engine-owned standard INDEX views.
- Require Twenty `>=2.26.0`: the engine-owned INDEX view fields this version relies on only exist from 2.26.
## 1.1.1
- Throttle backfill updates and retry rate-limited or transient API failures with exponential backoff, so install/upgrade no longer fails behind Cloudflare rate limiting.
## 1.1.0
- Add last contact on Companies and Opportunities.
- Set last-contact fields readonly.
## 1.0.0
- Initial release: "Last contact by" and "Last contact item" tracking on People, powered by calendar and message sync.

View File

@@ -0,0 +1,66 @@
## 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 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.

View File

@@ -0,0 +1,27 @@
# Last Contact
**Always know where every relationship stands — live on your People list, straight from your synced email and calendar.**
## ✨ What you get
- **Live columns on People, Companies and Opportunities** — last contact, who reached out (you or them), the owning teammate, and the exact email or meeting behind it
- **Zero upkeep** — updates in real time from every synced email and meeting, with your full history backfilled the moment you install
- **Follow-ups made obvious** — sort by recency to catch cold relationships and see who owes whom a reply
## 📊 The columns
On **People** you get:
- **Last contact** — the most recent touch, either direction
- **Last outbound** / **Last inbound** — when you last reached out vs. when they last did
- **Last contact by** — the teammate connected to this person
- **Last contact item** / **Last email** / **Last meeting** — one click to the actual record
On **Companies** and **Opportunities** you also get **Last contact** and **Last contact item** columns.
## 💳 Billing
**Free** — no credits, no metering.
## 📌 Heads up
Needs a synced inbox or calendar (Google, Outlook, or CalDAV). Direction is inferred from the email's sender and the meeting's organizer; a meeting counts as both.

View File

@@ -0,0 +1,37 @@
{
"name": "@twentyhq/last-contact",
"version": "1.3.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2",
"twenty": ">=2.40.0"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"typecheck": "tsgo --noEmit -p tsconfig.spec.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",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"twenty-client-sdk": "2.40.0",
"twenty-sdk": "2.40.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,55 @@
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import { describe, expect, it } from 'vitest';
import defaultRole from 'src/default-role';
const permissionFor = (objectUniversalIdentifier: string) =>
defaultRole.config?.objectPermissions?.find(
(permission) =>
permission.objectUniversalIdentifier === objectUniversalIdentifier,
);
describe('default role', () => {
it('only grants write access to the CRM records with last-contact fields', () => {
expect(defaultRole.success).toBe(true);
expect(defaultRole.config).toMatchObject({
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
});
for (const objectUniversalIdentifier of [
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
]) {
expect(permissionFor(objectUniversalIdentifier)).toMatchObject({
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
});
}
});
it('keeps interaction source records read-only', () => {
for (const objectUniversalIdentifier of [
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.messageParticipant
.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEventParticipant
.universalIdentifier,
]) {
expect(permissionFor(objectUniversalIdentifier)).toMatchObject({
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
});
}
expect(defaultRole.config?.objectPermissions).toHaveLength(7);
});
});

View File

@@ -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'}`,
);
}
}

View File

@@ -0,0 +1,35 @@
import { defineApplication, FieldType } from 'twenty-sdk/define';
import {
BACKFILL_BATCH_SIZE_ENV_VAR_NAME,
BACKFILL_SLEEP_MS_ENV_VAR_NAME,
DEFAULT_BACKFILL_BATCH_SIZE,
DEFAULT_BACKFILL_SLEEP_MS,
} from 'src/constants/backfill';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
logoUrl: 'public/logo.png',
author: 'Twenty',
category: 'Productivity',
screenshots: ['public/gallery/cover.png'],
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
serverVariables: {
[BACKFILL_BATCH_SIZE_ENV_VAR_NAME]: {
description: `How many records each last-contact backfill job processes. Also sets how many batch jobs are enqueued (record count divided by this). Defaults to ${DEFAULT_BACKFILL_BATCH_SIZE} when unset.`,
isSecret: false,
type: FieldType.NUMBER,
},
[BACKFILL_SLEEP_MS_ENV_VAR_NAME]: {
description: `How many milliseconds to stagger consecutive backfill jobs by, so they do not all run at once and hit the API rate limiting. Defaults to ${DEFAULT_BACKFILL_SLEEP_MS} when unset.`,
isSecret: false,
type: FieldType.NUMBER,
},
},
});

View File

@@ -0,0 +1,41 @@
import {
BACKFILL_COMPANIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
BACKFILL_OPPORTUNITIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
BACKFILL_PEOPLE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export type BackfillPhase = 'people' | 'opportunities' | 'companies';
export const BACKFILL_PHASE_ORDER: BackfillPhase[] = [
'people',
'opportunities',
'companies',
];
// GraphQL query field exposing the record connection for each phase.
export const BACKFILL_PHASE_QUERY_FIELD: Record<BackfillPhase, string> = {
people: 'people',
opportunities: 'opportunities',
companies: 'companies',
};
// Logic function each phase's batch jobs are enqueued against.
export const BACKFILL_PHASE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS: Record<
BackfillPhase,
string
> = {
people: BACKFILL_PEOPLE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
opportunities: BACKFILL_OPPORTUNITIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
companies: BACKFILL_COMPANIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
};
// A batch job resolves the records it owns from this index and the batch size.
export type BackfillBatchPayload = { batchId: number };
// Server variables, injected into process.env on every execution.
export const BACKFILL_BATCH_SIZE_ENV_VAR_NAME =
'LAST_CONTACT_BACKFILL_BATCH_SIZE';
export const BACKFILL_SLEEP_MS_ENV_VAR_NAME = 'LAST_CONTACT_BACKFILL_SLEEP_MS';
export const DEFAULT_BACKFILL_BATCH_SIZE = 20;
export const DEFAULT_BACKFILL_SLEEP_MS = 1_000;

View File

@@ -0,0 +1 @@
export const BATCH_HANDLER_TIMEOUT_SECONDS = 300;

View File

@@ -0,0 +1 @@
export const CALENDAR_CRON_INTERVAL_MINUTES = 5;

View File

@@ -0,0 +1 @@
export const CALENDAR_CRON_SECURITY_OVERLAP_MINUTES = 10;

View File

@@ -0,0 +1,86 @@
export const APP_DISPLAY_NAME = 'Last contact';
export const APP_DESCRIPTION =
'Know where every relationship stands. Adds Last contact, Last outbound, Last inbound, Last contact by, and the last email and meeting to People, plus Last contact on Companies and Opportunities, kept up to date automatically from your synced emails and meetings.';
export const APPLICATION_UNIVERSAL_IDENTIFIER = '66a504cc-0a75-410e-a43f-cdeae1db1522';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = '34187abe-1b98-4153-85cd-4808e0aebf30';
export const LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER =
'4e0fd7ff-0bbc-47b2-baab-5fe2c0d12557';
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 BACKFILL_PEOPLE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'a1e15917-86d6-49a4-90d5-6a2555abc6f5';
export const BACKFILL_COMPANIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'086d247f-28da-41be-8edd-e6c58592d666';
export const BACKFILL_OPPORTUNITIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'af176746-4334-47f8-a017-88a474a03fa3';
export const OPPORTUNITY_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'6659b54f-2f46-412b-aa7d-03aa9f1c5133';
export const OPPORTUNITY_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'071a1c3e-b580-4312-806d-ff27287292ae';
export const COMPANY_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'd0631934-2f06-4799-9b33-6937f78e16ff';
export const PERSON_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'cf7460cc-4c48-4412-9a1a-0c30e4960fce';
export const PERSON_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'a967064f-f440-43cd-bfbe-907c95d4563f';
export const LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER =
'cfdee7bd-8d41-41e6-a888-512705e75d7b';
export const LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER =
'bec41e57-6b6f-4bb2-90cc-4030384dd8f6';
export const LAST_CONTACT_ITEM_MORPH_ID =
'97eafe97-0ee1-4857-b233-fec855f6bf1a';
export const LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
'e4abd1da-32f4-4025-8908-e7834e85b8e2';
export const LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
'4dea9957-ff7d-46b8-988f-cc72f187d4b5';
export const LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'cceb21fe-1422-40d4-bc8b-44c12c79c452';
export const LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'f49ce9ca-5aab-445d-9749-db256aaf74b7';
export const LAST_OUTBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER =
'f4bbac22-8a3d-44c2-bbf2-7e28237252e1';
export const LAST_INBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER =
'11ee7749-735d-4fcf-b127-fa6856de7ee0';
export const LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER =
'fd705b81-c6c6-45d3-9777-818956b7627b';
export const LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
'7a3dcccb-d4fd-4839-971a-775ca7bd897f';
export const LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER =
'c8882287-f638-4a96-a235-1819e793e373';
export const LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'257dd874-d834-403d-9cb8-f3db7e587d02';
export const COMPANY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER =
'4d84b45d-128d-460f-858a-f877bf6e58ac';
export const COMPANY_LAST_CONTACT_ITEM_MORPH_ID =
'64a265e9-c597-4ca4-a5f2-570d9661eea7';
export const COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
'f43de098-74a9-491c-b718-2e132332c722';
export const COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'4ef5546f-97e3-4eff-8090-91b5c6400ac9';
export const LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
'9f08c2e0-23ba-413c-94c8-04ba828586a3';
export const LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'79103905-cfdb-413a-b964-8f347332ef3c';
export const OPPORTUNITY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER =
'69225d61-0efb-4959-af6d-566caba58412';
export const OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID =
'9199fef1-06e3-4024-8a4a-b4eee554418e';
export const OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
'0b34dd29-35e7-4082-bfd1-551513132ba1';
export const OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'1f1645d4-d141-413b-a07d-c428240eed2f';
export const LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER =
'1ccff5c2-44c3-4a7d-847f-c74440fe56a9';
export const LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'db36a44e-a6dc-4bec-a23e-3b27f63d1629';

View File

@@ -0,0 +1,53 @@
import {
defineApplicationRole,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} 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} function role`,
description:
'Reads synced email and calendar interactions, then updates last-contact fields on people, companies, and opportunities.',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
...[
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
].map((objectUniversalIdentifier) => ({
objectUniversalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
})),
...[
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.messageParticipant
.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEventParticipant
.universalIdentifier,
].map((objectUniversalIdentifier) => ({
objectUniversalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
})),
],
fieldPermissions: [],
permissionFlagUniversalIdentifiers: [],
});

View File

@@ -0,0 +1,21 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { COMPANY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: COMPANY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
name: 'lastContactAt',
type: FieldType.DATE_TIME,
label: 'Last contact',
description:
'When the most recent contact (email or meeting) with a person from this company occurred, in either direction.',
icon: 'IconClock',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,38 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
COMPANY_LAST_CONTACT_ITEM_MORPH_ID,
LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.MORPH_RELATION,
name: 'lastContactItemCalendarEvent',
label: 'Last contact item',
description:
'The email or meeting that was the most recent contact with a person from this company.',
icon: 'IconCalendarEvent',
isNullable: true,
morphId: COMPANY_LAST_CONTACT_ITEM_MORPH_ID,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastContactItemCalendarEventId',
},
isUIEditable: false,
});

View File

@@ -0,0 +1,38 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
COMPANY_LAST_CONTACT_ITEM_MORPH_ID,
LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.MORPH_RELATION,
name: 'lastContactItemMessage',
label: 'Last contact item',
description:
'The email or meeting that was the most recent contact with a person from this company.',
icon: 'IconMessage',
isNullable: true,
morphId: COMPANY_LAST_CONTACT_ITEM_MORPH_ID,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastContactItemMessageId',
},
isUIEditable: false,
});

View File

@@ -0,0 +1,21 @@
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 contact (email or meeting) with this person occurred, in either direction.',
icon: 'IconClock',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,35 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'lastContactBy',
label: 'Last contact by',
description:
'The team member whose synced email or meeting was the most recent interaction with this person.',
icon: 'IconUser',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastContactById',
},
isUIEditable: false,
});

View File

@@ -0,0 +1,32 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
LAST_CONTACT_FOR_COMPANIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
type: FieldType.RELATION,
name: 'lastContactForCompanies',
label: 'Last contact for companies',
description: 'Companies whose most recent contact was this meeting.',
icon: 'IconBuildingSkyscraper',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
COMPANY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
isUIEditable: false,
});

View File

@@ -0,0 +1,32 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
LAST_CONTACT_FOR_COMPANIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
type: FieldType.RELATION,
name: 'lastContactForCompanies',
label: 'Last contact for companies',
description: 'Companies whose most recent contact was this email.',
icon: 'IconBuildingSkyscraper',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
COMPANY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
isUIEditable: false,
});

View File

@@ -0,0 +1,32 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
type: FieldType.RELATION,
name: 'lastContactForOpportunities',
label: 'Last contact for opportunities',
description: 'Opportunities whose most recent contact was this meeting.',
icon: 'IconTargetArrow',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
isUIEditable: false,
});

View File

@@ -0,0 +1,32 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
type: FieldType.RELATION,
name: 'lastContactForOpportunities',
label: 'Last contact for opportunities',
description: 'Opportunities whose most recent contact was this email.',
icon: 'IconTargetArrow',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
isUIEditable: false,
});

View File

@@ -0,0 +1,32 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
type: FieldType.RELATION,
name: 'lastContactForPeople',
label: 'Last contact for',
description: 'People whose most recent contact was this meeting.',
icon: 'IconUser',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
isUIEditable: false,
});

View File

@@ -0,0 +1,32 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
type: FieldType.RELATION,
name: 'lastContactForPeople',
label: 'Last contact for',
description: 'People whose most recent contact was this email.',
icon: 'IconUser',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
isUIEditable: false,
});

View File

@@ -0,0 +1,32 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
type: FieldType.RELATION,
name: 'lastContactForPeople',
label: 'Last contact for',
description: 'People whose most recent contact was through this member.',
icon: 'IconUser',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
isUIEditable: false,
});

View File

@@ -0,0 +1,37 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_MORPH_ID,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.MORPH_RELATION,
name: 'lastContactItemCalendarEvent',
label: 'Last contact item',
description: 'The email or meeting that was the most recent contact.',
icon: 'IconCalendarEvent',
isNullable: true,
morphId: LAST_CONTACT_ITEM_MORPH_ID,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastContactItemCalendarEventId',
},
isUIEditable: false,
});

View File

@@ -0,0 +1,36 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
LAST_CONTACT_ITEM_MORPH_ID,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.MORPH_RELATION,
name: 'lastContactItemMessage',
label: 'Last contact item',
description: 'The email or meeting that was the most recent contact.',
icon: 'IconMessage',
isNullable: true,
morphId: LAST_CONTACT_ITEM_MORPH_ID,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastContactItemMessageId',
},
isUIEditable: false,
});

View File

@@ -0,0 +1,31 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
type: FieldType.RELATION,
name: 'lastEmailForPeople',
label: 'Last email for',
description: 'People whose most recent email is this one.',
icon: 'IconUser',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
isUIEditable: false,
});

View File

@@ -0,0 +1,34 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'lastEmail',
label: 'Last email',
description: 'The most recent email exchanged with this person.',
icon: 'IconMail',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastEmailId',
},
isUIEditable: false,
});

View File

@@ -0,0 +1,21 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { LAST_INBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_INBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
name: 'lastInboundAt',
type: FieldType.DATE_TIME,
label: 'Last inbound',
description:
'When this person last reached out to you (an inbound email, or a meeting they organized).',
icon: 'IconMessageDown',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,31 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER,
LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
type: FieldType.RELATION,
name: 'lastMeetingForPeople',
label: 'Last meeting for',
description: 'People whose most recent meeting is this one.',
icon: 'IconUser',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
isUIEditable: false,
});

View File

@@ -0,0 +1,34 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER,
LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'lastMeeting',
label: 'Last meeting',
description: 'The most recent meeting with this person.',
icon: 'IconCalendarEvent',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastMeetingId',
},
isUIEditable: false,
});

View File

@@ -0,0 +1,21 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { LAST_OUTBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: LAST_OUTBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
name: 'lastOutboundAt',
type: FieldType.DATE_TIME,
label: 'Last outbound',
description:
'When your team last reached out to this person (an outbound email, or a meeting your team organized).',
icon: 'IconMessageUp',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,21 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { OPPORTUNITY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: OPPORTUNITY_LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
name: 'lastContactAt',
type: FieldType.DATE_TIME,
label: 'Last contact',
description:
'When the most recent contact (email or meeting) with a person related to this opportunity occurred, in either direction.',
icon: 'IconClock',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,38 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
OPPORTUNITY_LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.MORPH_RELATION,
name: 'lastContactItemCalendarEvent',
label: 'Last contact item',
description:
'The email or meeting that was the most recent contact with a person related to this opportunity.',
icon: 'IconCalendarEvent',
isNullable: true,
morphId: OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_FOR_OPPORTUNITIES_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastContactItemCalendarEventId',
},
isUIEditable: false,
});

View File

@@ -0,0 +1,38 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
OPPORTUNITY_LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.MORPH_RELATION,
name: 'lastContactItemMessage',
label: 'Last contact item',
description:
'The email or meeting that was the most recent contact with a person related to this opportunity.',
icon: 'IconMessage',
isNullable: true,
morphId: OPPORTUNITY_LAST_CONTACT_ITEM_MORPH_ID,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LAST_CONTACT_FOR_OPPORTUNITIES_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'lastContactItemMessageId',
},
isUIEditable: false,
});

View File

@@ -0,0 +1,183 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(function () {
return { query: queryMock, mutation: mutationMock };
}),
}));
import 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 singlePage = (nodes: Record<string, unknown>[]): Page => ({
edges: nodes.map((node) => ({ node })),
pageInfo: { hasNextPage: false, endCursor: null },
});
const setupQueryMock = ({
calendarEventsPages,
participantsPages,
startedParticipants = [],
}: {
calendarEventsPages: Page[];
participantsPages: Page[];
startedParticipants?: Record<string, unknown>[];
}) => {
const remainingEventsPages = [...calendarEventsPages];
const remainingParticipantsPages = [...participantsPages];
queryMock.mockImplementation((query) => {
if (query.calendarEvents) {
return Promise.resolve({ calendarEvents: remainingEventsPages.shift() });
}
if (query.calendarEventParticipants) {
return Promise.resolve({
calendarEventParticipants: query.calendarEventParticipants.edges.node
.calendarEvent
? singlePage(startedParticipants)
: remainingParticipantsPages.shift(),
});
}
if (query.person) {
return Promise.resolve({ person: null });
}
if (query.people) {
return Promise.resolve({ people: singlePage([]) });
}
return Promise.resolve({ opportunities: singlePage([]) });
});
};
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({ updatePeople: [{ id: 'updated' }] });
});
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: { personId: PERSON_ID_1, calendarEventId: 'event-1' } },
{ node: { personId: null, calendarEventId: 'event-1' } },
],
pageInfo: { hasNextPage: true, endCursor: 'participants-cursor-1' },
},
singlePage([
{ personId: PERSON_ID_1, calendarEventId: 'event-1' },
{ personId: PERSON_ID_2, calendarEventId: 'event-1' },
]),
],
startedParticipants: [
{
calendarEventId: 'event-1',
isOrganizer: null,
workspaceMemberId: null,
calendarEvent: {
startsAt: PAST_EVENT_STARTS_AT,
isCanceled: false,
},
},
],
});
await handler();
const participantsByEventCalls = queryMock.mock.calls.filter(
([query]) =>
query.calendarEventParticipants?.__args.filter.calendarEventId &&
!query.calendarEventParticipants?.edges.node.calendarEvent,
);
expect(participantsByEventCalls).toHaveLength(2);
expect(
participantsByEventCalls[1][0].calendarEventParticipants.__args.after,
).toBe('participants-cursor-1');
const personUpdates = mutationMock.mock.calls.filter(
([mutation]) => mutation.updatePeople,
);
expect(personUpdates).toHaveLength(2);
expect(personUpdates[0][0].updatePeople.__args.data).toMatchObject({
lastContactAt: PAST_EVENT_STARTS_AT,
lastContactItemCalendarEventId: 'event-1',
});
});
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();
});
});

View File

@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(function () {
return { query: queryMock, mutation: mutationMock };
}),
}));
import onCalendarInteraction from '../on-calendar-interaction';
const PERSON_ID = '11111111-1111-1111-1111-111111111111';
const CALENDAR_EVENT_ID = '22222222-2222-2222-2222-222222222222';
const NOW = '2026-06-12T12:00:00.000Z';
const PAST_EVENT_STARTS_AT = '2026-06-10T09:00:00.000Z';
const handler = onCalendarInteraction.config.handler as (
batch: unknown,
) => Promise<void>;
const buildPage = (nodes: Record<string, unknown>[]) => ({
edges: nodes.map((node) => ({ node })),
pageInfo: { hasNextPage: false, endCursor: null },
});
const buildBatch = (
participants: {
personId: string | null;
calendarEventId: string | null;
}[],
) => ({
name: 'calendarEventParticipant.updated',
events: participants.map(({ personId, calendarEventId }, index) => ({
recordId: `participant-${index}`,
properties: {
updatedFields: ['personId'],
after: { id: `participant-${index}`, personId, calendarEventId },
},
})),
});
const setupQueryMock = (participants: Record<string, unknown>[]) => {
queryMock.mockImplementation((query) => {
if (query.calendarEventParticipants) {
return Promise.resolve({
calendarEventParticipants: buildPage(participants),
});
}
if (query.person) {
return Promise.resolve({ person: null });
}
if (query.people) {
return Promise.resolve({ people: buildPage([]) });
}
return Promise.resolve({ opportunities: buildPage([]) });
});
};
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(NOW));
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({ updatePeople: [{ id: 'updated' }] });
});
afterEach(() => {
vi.useRealTimers();
});
describe('on-calendar-interaction definition', () => {
it('should be valid and batch personId updates', () => {
expect(onCalendarInteraction.success).toBe(true);
expect(onCalendarInteraction.config.databaseEventTriggerSettings).toEqual({
eventName: 'calendarEventParticipant.updated',
updatedFields: ['personId'],
batchMode: true,
});
});
});
describe('on-calendar-interaction handler', () => {
it('updates the person from the calendar event carried by the participant', async () => {
setupQueryMock([
{
calendarEventId: CALENDAR_EVENT_ID,
isOrganizer: null,
workspaceMemberId: null,
calendarEvent: {
startsAt: PAST_EVENT_STARTS_AT,
isCanceled: false,
},
},
]);
await handler(
buildBatch([
{ personId: PERSON_ID, calendarEventId: CALENDAR_EVENT_ID },
]),
);
expect(
queryMock.mock.calls[0][0].calendarEventParticipants.__args.filter,
).toEqual({ calendarEventId: { in: [CALENDAR_EVENT_ID] } });
expect(mutationMock.mock.calls[0][0].updatePeople.__args.data).toEqual({
lastContactAt: PAST_EVENT_STARTS_AT,
lastContactById: null,
lastContactItemCalendarEventId: CALENDAR_EVENT_ID,
lastContactItemMessageId: null,
lastOutboundAt: PAST_EVENT_STARTS_AT,
lastInboundAt: PAST_EVENT_STARTS_AT,
lastMeetingId: CALENDAR_EVENT_ID,
});
});
it('should do nothing when no participant has both a person and a calendar event', async () => {
await handler(
buildBatch([
{ personId: null, calendarEventId: CALENDAR_EVENT_ID },
{ personId: PERSON_ID, calendarEventId: null },
]),
);
expect(queryMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,186 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(function () {
return { query: queryMock, mutation: mutationMock };
}),
}));
import onEmailInteraction from '../on-email-interaction';
const PERSON_ID = '11111111-1111-1111-1111-111111111111';
const MESSAGE_ID = '22222222-2222-2222-2222-222222222222';
const OLDER_MESSAGE_ID = '44444444-4444-4444-4444-444444444444';
const MEMBER_ID = '33333333-3333-3333-3333-333333333333';
const RECEIVED_AT = '2026-06-10T09:00:00.000Z';
const OLDER_RECEIVED_AT = '2026-06-01T09:00:00.000Z';
const handler = onEmailInteraction.config.handler as (
batch: unknown,
) => Promise<void>;
const buildPage = (nodes: Record<string, unknown>[]) => ({
edges: nodes.map((node) => ({ node })),
pageInfo: { hasNextPage: false, endCursor: null },
});
const buildBatch = (
participants: { personId: string | null; messageId: string | null }[],
) => ({
name: 'messageParticipant.updated',
events: participants.map(({ personId, messageId }, index) => ({
recordId: `participant-${index}`,
properties: {
updatedFields: ['personId'],
after: { id: `participant-${index}`, personId, messageId },
},
})),
});
const setupQueryMock = (messageParticipants: Record<string, unknown>[]) => {
queryMock.mockImplementation((query) => {
if (query.messageParticipants) {
return Promise.resolve({
messageParticipants: buildPage(messageParticipants),
});
}
if (query.person) {
return Promise.resolve({ person: null });
}
if (query.people) {
return Promise.resolve({ people: buildPage([]) });
}
return Promise.resolve({ opportunities: buildPage([]) });
});
};
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({ updatePeople: [{ id: 'updated' }] });
});
describe('on-email-interaction definition', () => {
it('should be valid and batch personId updates', () => {
expect(onEmailInteraction.success).toBe(true);
expect(onEmailInteraction.config.databaseEventTriggerSettings).toEqual({
eventName: 'messageParticipant.updated',
updatedFields: ['personId'],
batchMode: true,
});
});
});
describe('on-email-interaction handler', () => {
it('sets interaction, owner, item, contacted and lastEmail for an outbound email', async () => {
setupQueryMock([
{
messageId: MESSAGE_ID,
role: 'TO',
workspaceMemberId: null,
message: { receivedAt: RECEIVED_AT },
},
{
messageId: MESSAGE_ID,
role: 'FROM',
workspaceMemberId: MEMBER_ID,
message: { receivedAt: RECEIVED_AT },
},
]);
await handler(
buildBatch([{ personId: PERSON_ID, messageId: MESSAGE_ID }]),
);
expect(mutationMock.mock.calls[0][0].updatePeople.__args.data).toEqual({
lastContactAt: RECEIVED_AT,
lastContactById: MEMBER_ID,
lastContactItemMessageId: MESSAGE_ID,
lastContactItemCalendarEventId: null,
lastOutboundAt: RECEIVED_AT,
lastEmailId: MESSAGE_ID,
});
});
it('resolves every message of the batch in one query and updates each person once', async () => {
setupQueryMock([
{
messageId: OLDER_MESSAGE_ID,
role: 'FROM',
workspaceMemberId: null,
message: { receivedAt: OLDER_RECEIVED_AT },
},
{
messageId: MESSAGE_ID,
role: 'FROM',
workspaceMemberId: MEMBER_ID,
message: { receivedAt: RECEIVED_AT },
},
]);
await handler(
buildBatch([
{ personId: PERSON_ID, messageId: OLDER_MESSAGE_ID },
{ personId: PERSON_ID, messageId: MESSAGE_ID },
]),
);
const participantQueries = queryMock.mock.calls.filter(
([query]) => query.messageParticipants,
);
expect(participantQueries).toHaveLength(1);
expect(
participantQueries[0][0].messageParticipants.__args.filter,
).toEqual({ messageId: { in: [OLDER_MESSAGE_ID, MESSAGE_ID] } });
const personUpdates = mutationMock.mock.calls.filter(
([mutation]) => mutation.updatePeople,
);
expect(personUpdates).toHaveLength(1);
expect(personUpdates[0][0].updatePeople.__args.data).toEqual({
lastContactAt: RECEIVED_AT,
lastContactById: MEMBER_ID,
lastContactItemMessageId: MESSAGE_ID,
lastContactItemCalendarEventId: null,
lastOutboundAt: RECEIVED_AT,
lastInboundAt: OLDER_RECEIVED_AT,
lastEmailId: MESSAGE_ID,
});
});
it('should do nothing when no participant has both a person and a message', async () => {
await handler(
buildBatch([
{ personId: null, messageId: MESSAGE_ID },
{ 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 () => {
setupQueryMock([
{
messageId: MESSAGE_ID,
role: 'FROM',
workspaceMemberId: MEMBER_ID,
message: { receivedAt: null },
},
]);
await handler(
buildBatch([{ personId: PERSON_ID, messageId: MESSAGE_ID }]),
);
expect(mutationMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(function () {
return { query: queryMock, mutation: mutationMock };
}),
}));
import onOpportunityCreated from '../on-opportunity-created';
const OPPORTUNITY_ID = '11111111-1111-1111-1111-111111111111';
const PERSON_ID = '22222222-2222-2222-2222-222222222222';
const MESSAGE_ID = '33333333-3333-3333-3333-333333333333';
const OCCURRED_AT = '2026-06-10T09:00:00.000Z';
const handler = onOpportunityCreated.config.handler as (
batch: unknown,
) => Promise<void>;
const buildPage = (nodes: Record<string, unknown>[]) => ({
edges: nodes.map((node) => ({ node })),
pageInfo: { hasNextPage: false, endCursor: null },
});
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({});
});
describe('on-opportunity-created', () => {
it('should batch opportunity creations', () => {
expect(onOpportunityCreated.success).toBe(true);
expect(onOpportunityCreated.config.databaseEventTriggerSettings).toEqual({
eventName: 'opportunity.created',
batchMode: true,
});
});
it('computes the last contact from the point of contact', async () => {
queryMock.mockImplementation((query) =>
query.opportunities
? Promise.resolve({
opportunities: buildPage([
{ id: OPPORTUNITY_ID, pointOfContactId: PERSON_ID },
]),
})
: Promise.resolve({
people: buildPage([
{
id: PERSON_ID,
lastContactAt: OCCURRED_AT,
lastContactItemMessage: { id: MESSAGE_ID },
lastContactItemCalendarEvent: null,
},
]),
}),
);
await handler({
name: 'opportunity.created',
events: [
{
recordId: OPPORTUNITY_ID,
properties: { after: { id: OPPORTUNITY_ID } },
},
],
});
expect(mutationMock.mock.calls[0][0].updateOpportunity.__args.data).toEqual({
lastContactAt: OCCURRED_AT,
lastContactItemMessageId: MESSAGE_ID,
lastContactItemCalendarEventId: null,
});
});
});

View File

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(function () {
return { query: queryMock, mutation: mutationMock };
}),
}));
import onPersonUpdated from '../on-person-updated';
const OLD_COMPANY_ID = '11111111-1111-1111-1111-111111111111';
const NEW_COMPANY_ID = '22222222-2222-2222-2222-222222222222';
const handler = onPersonUpdated.config.handler as (
batch: unknown,
) => Promise<void>;
const buildBatch = (
moves: { before: string | null; after: string | null }[],
) => ({
name: 'person.updated',
events: moves.map(({ before, after }, index) => ({
recordId: `person-${index}`,
properties: {
updatedFields: ['companyId'],
before: { id: `person-${index}`, companyId: before },
after: { id: `person-${index}`, companyId: after },
},
})),
});
beforeEach(() => {
queryMock.mockReset();
queryMock.mockResolvedValue({ people: { edges: [] } });
mutationMock.mockReset();
mutationMock.mockResolvedValue({});
});
const updatedCompanyIds = () =>
mutationMock.mock.calls.map(([mutation]) => mutation.updateCompany.__args.id);
describe('on-person-updated definition', () => {
it('should batch companyId updates', () => {
expect(onPersonUpdated.success).toBe(true);
expect(onPersonUpdated.config.databaseEventTriggerSettings).toEqual({
eventName: 'person.updated',
updatedFields: ['companyId'],
batchMode: true,
});
});
});
describe('on-person-updated handler', () => {
it('recomputes both the former and the current company', async () => {
await handler(
buildBatch([{ before: OLD_COMPANY_ID, after: NEW_COMPANY_ID }]),
);
expect(updatedCompanyIds()).toEqual([OLD_COMPANY_ID, NEW_COMPANY_ID]);
});
it('recomputes a single company when the person only gains one', async () => {
await handler(buildBatch([{ before: null, after: NEW_COMPANY_ID }]));
expect(updatedCompanyIds()).toEqual([NEW_COMPANY_ID]);
});
it('recomputes a company shared by several people in the batch only once', async () => {
await handler(
buildBatch([
{ before: OLD_COMPANY_ID, after: NEW_COMPANY_ID },
{ before: OLD_COMPANY_ID, after: NEW_COMPANY_ID },
]),
);
expect(updatedCompanyIds()).toEqual([OLD_COMPANY_ID, NEW_COMPANY_ID]);
});
it('does nothing when no company is involved', async () => {
await handler(buildBatch([{ before: null, after: null }]));
expect(queryMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,72 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { type BackfillBatchPayload } from 'src/constants/backfill';
import { BACKFILL_COMPANIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { buildBackfillBatchArgs } from 'src/utils/backfill-batch-args';
import { getBackfillBatchSize } from 'src/utils/backfill-settings';
import { collectPeopleByCompany } from 'src/utils/collect-people-by-company';
import { executeWithRetry } from 'src/utils/execute-with-retry';
import {
buildRelatedUpdateData,
buildPersonAggregates,
pickLatestLastContact,
pickPersonLastContact,
} from 'src/utils/person-last-contact-aggregation';
const handler = async ({ batchId }: BackfillBatchPayload): Promise<object> => {
const client = new CoreApiClient();
const { companies } = await executeWithRetry(() =>
client.query({
companies: {
__args: buildBackfillBatchArgs(batchId, getBackfillBatchSize()),
edges: { node: { id: true } },
},
}),
);
const companyIds = (companies?.edges ?? [])
.map((edge: { node: { id: string } }) => edge.node.id)
.filter(Boolean);
if (companyIds.length === 0) {
return { batchId, count: 0 };
}
const peopleByCompanyId = await collectPeopleByCompany(client, companyIds);
const personIds = [...new Set([...peopleByCompanyId.values()].flat())];
const aggByPersonId = await buildPersonAggregates(client, personIds);
for (const companyId of companyIds) {
const lastContact = pickLatestLastContact(
(peopleByCompanyId.get(companyId) ?? [])
.map((personId) => pickPersonLastContact(aggByPersonId.get(personId)))
.filter((contact) => contact !== undefined),
);
if (!lastContact) {
continue;
}
await executeWithRetry(() =>
client.mutation({
updateCompany: {
__args: { id: companyId, data: buildRelatedUpdateData(lastContact) },
id: true,
},
}),
);
}
return { batchId, count: companyIds.length };
};
export default defineLogicFunction({
universalIdentifier: BACKFILL_COMPANIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'backfill-companies-last-contact',
description:
'Backfills last-contact fields for one batch of companies from the most recent contact of their people, resolved from the batch id in its payload.',
timeoutSeconds: 120,
handler,
});

View File

@@ -0,0 +1,33 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { definePostInstallLogicFunction } from 'twenty-sdk/define';
import { BACKFILL_POST_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import {
getBackfillBatchSize,
getBackfillSleepMs,
} from 'src/utils/backfill-settings';
import { enqueueBackfillJobs } from 'src/utils/enqueue-backfill-jobs';
const handler = async (): Promise<object> => {
console.log(
'Backfill params',
JSON.stringify({
batchSize: getBackfillBatchSize(),
sleepMs: getBackfillSleepMs(),
}),
);
const plans = await enqueueBackfillJobs(new CoreApiClient());
return { outcome: 'enqueued', plans };
};
export default definePostInstallLogicFunction({
universalIdentifier: BACKFILL_POST_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'backfill-last-contact',
description:
'Counts people, opportunities and companies after installation and enqueues one backfill job per record batch.',
timeoutSeconds: 300,
shouldRunOnVersionUpgrade: false,
handler,
});

View File

@@ -0,0 +1,76 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { type BackfillBatchPayload } from 'src/constants/backfill';
import { BACKFILL_OPPORTUNITIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { buildBackfillBatchArgs } from 'src/utils/backfill-batch-args';
import { getBackfillBatchSize } from 'src/utils/backfill-settings';
import { executeWithRetry } from 'src/utils/execute-with-retry';
import {
buildRelatedUpdateData,
buildPersonAggregates,
pickPersonLastContact,
} from 'src/utils/person-last-contact-aggregation';
type OpportunityNode = { id: string; pointOfContactId: string | null };
const handler = async ({ batchId }: BackfillBatchPayload): Promise<object> => {
const client = new CoreApiClient();
const { opportunities } = await executeWithRetry(() =>
client.query({
opportunities: {
__args: buildBackfillBatchArgs(batchId, getBackfillBatchSize()),
edges: { node: { id: true, pointOfContactId: true } },
},
}),
);
const nodes: OpportunityNode[] = (opportunities?.edges ?? [])
.map((edge: { node: OpportunityNode }) => edge.node)
.filter((node: OpportunityNode) => Boolean(node.id));
if (nodes.length === 0) {
return { batchId, count: 0 };
}
const personIds = [
...new Set(
nodes
.map((node) => node.pointOfContactId)
.filter((id): id is string => Boolean(id)),
),
];
const aggByPersonId = await buildPersonAggregates(client, personIds);
for (const node of nodes) {
const lastContact = node.pointOfContactId
? pickPersonLastContact(aggByPersonId.get(node.pointOfContactId))
: undefined;
if (!lastContact) {
continue;
}
await executeWithRetry(() =>
client.mutation({
updateOpportunity: {
__args: { id: node.id, data: buildRelatedUpdateData(lastContact) },
id: true,
},
}),
);
}
return { batchId, count: nodes.length };
};
export default defineLogicFunction({
universalIdentifier:
BACKFILL_OPPORTUNITIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'backfill-opportunities-last-contact',
description:
'Backfills last-contact fields for one batch of opportunities from their point of contact, resolved from the batch id in its payload.',
timeoutSeconds: 120,
handler,
});

View File

@@ -0,0 +1,61 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { type BackfillBatchPayload } from 'src/constants/backfill';
import { BACKFILL_PEOPLE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { buildBackfillBatchArgs } from 'src/utils/backfill-batch-args';
import { getBackfillBatchSize } from 'src/utils/backfill-settings';
import { executeWithRetry } from 'src/utils/execute-with-retry';
import {
buildPersonAggregates,
buildPersonUpdateData,
} from 'src/utils/person-last-contact-aggregation';
const handler = async ({ batchId }: BackfillBatchPayload): Promise<object> => {
const client = new CoreApiClient();
const { people } = await executeWithRetry(() =>
client.query({
people: {
__args: buildBackfillBatchArgs(batchId, getBackfillBatchSize()),
edges: { node: { id: true } },
},
}),
);
const personIds = (people?.edges ?? [])
.map((edge: { node: { id: string } }) => edge.node.id)
.filter(Boolean);
if (personIds.length === 0) {
return { batchId, count: 0 };
}
const aggByPersonId = await buildPersonAggregates(client, personIds);
for (const personId of personIds) {
const agg = aggByPersonId.get(personId);
const data = agg ? buildPersonUpdateData(agg) : {};
if (Object.keys(data).length === 0) {
continue;
}
await executeWithRetry(() =>
client.mutation({
updatePerson: { __args: { id: personId, data }, id: true },
}),
);
}
return { batchId, count: personIds.length };
};
export default defineLogicFunction({
universalIdentifier: BACKFILL_PEOPLE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'backfill-people-last-contact',
description:
'Backfills last-contact fields for one batch of people, resolved from the batch id in its payload.',
timeoutSeconds: 120,
handler,
});

View File

@@ -0,0 +1,122 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { BATCH_HANDLER_TIMEOUT_SECONDS } from 'src/constants/batch-handler-timeout-seconds';
import { CALENDAR_CRON_INTERVAL_MINUTES } from 'src/constants/calendar-cron-interval-minutes';
import { CALENDAR_CRON_SECURITY_OVERLAP_MINUTES } from 'src/constants/calendar-cron-security-overlap-minutes';
import { CALENDAR_EVENT_STARTED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import {
applyMeetingInteractions,
type CalendarEventParticipantLink,
} from 'src/utils/apply-meeting-interactions';
const QUERY_MAX_RECORDS = 200;
const handler = async (): Promise<void> => {
const client = new CoreApiClient();
const now = new Date();
const windowStart = new Date(
now.getTime() -
(CALENDAR_CRON_INTERVAL_MINUTES + CALENDAR_CRON_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: { node: { id: string } }) => edge.node.id) ?? []),
);
calendarEventsHasNextPage = calendarEvents?.pageInfo.hasNextPage ?? false;
calendarEventsCursor = calendarEvents?.pageInfo.endCursor ?? undefined;
}
if (calendarEventIds.length === 0) {
return;
}
const linkByKey = new Map<string, CalendarEventParticipantLink>();
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: {
personId: true,
calendarEventId: true,
},
},
pageInfo: {
hasNextPage: true,
endCursor: true,
},
},
});
for (const edge of calendarEventParticipants?.edges ?? []) {
const { personId, calendarEventId } = edge.node;
if (personId && calendarEventId) {
linkByKey.set(`${personId}:${calendarEventId}`, {
personId,
calendarEventId,
});
}
}
participantsHasNextPage =
calendarEventParticipants?.pageInfo.hasNextPage ?? false;
participantsCursor =
calendarEventParticipants?.pageInfo.endCursor ?? undefined;
}
await applyMeetingInteractions(client, [...linkByKey.values()]);
};
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: BATCH_HANDLER_TIMEOUT_SECONDS,
cronTriggerSettings: {
pattern: `*/${CALENDAR_CRON_INTERVAL_MINUTES} * * * *`,
},
handler,
});

View File

@@ -0,0 +1,58 @@
import {
defineLogicFunction,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk/define';
import { type DatabaseEventBatchPayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { BATCH_HANDLER_TIMEOUT_SECONDS } from 'src/constants/batch-handler-timeout-seconds';
import { CALENDAR_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import {
applyMeetingInteractions,
type CalendarEventParticipantLink,
} from 'src/utils/apply-meeting-interactions';
type CalendarEventParticipantUpdate = {
personId?: string | null;
calendarEventId?: string | null;
};
const handler = async (
batch: DatabaseEventBatchPayload<
ObjectRecordUpdateEvent<CalendarEventParticipantUpdate>
>,
): Promise<void> => {
const linkByKey = new Map<string, CalendarEventParticipantLink>();
for (const event of batch.events) {
const personId = event.properties.after.personId;
const calendarEventId = event.properties.after.calendarEventId;
if (personId && calendarEventId) {
linkByKey.set(`${personId}:${calendarEventId}`, {
personId,
calendarEventId,
});
}
}
if (linkByKey.size === 0) {
return;
}
await applyMeetingInteractions(new CoreApiClient(), [...linkByKey.values()]);
};
export default defineLogicFunction({
universalIdentifier: CALENDAR_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-calendar-interaction',
description:
"Updates a person's last-contacted fields, and the last contact on their company and opportunities, when a new calendar event participant is created (past events only).",
timeoutSeconds: BATCH_HANDLER_TIMEOUT_SECONDS,
databaseEventTriggerSettings: {
eventName: 'calendarEventParticipant.updated',
updatedFields: ['personId'],
batchMode: true,
},
handler,
});

View File

@@ -0,0 +1,45 @@
import {
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { type DatabaseEventBatchPayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { BATCH_HANDLER_TIMEOUT_SECONDS } from 'src/constants/batch-handler-timeout-seconds';
import { COMPANY_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { recomputeCompaniesLastContact } from 'src/utils/recompute-company-last-contact';
type CompanyCreate = { id?: string | null };
const handler = async (
batch: DatabaseEventBatchPayload<ObjectRecordCreateEvent<CompanyCreate>>,
): Promise<void> => {
const companyIds = new Set<string>();
for (const event of batch.events) {
const companyId = event.properties.after.id ?? event.recordId;
if (companyId) {
companyIds.add(companyId);
}
}
if (companyIds.size === 0) {
return;
}
await recomputeCompaniesLastContact(new CoreApiClient(), [...companyIds]);
};
export default defineLogicFunction({
universalIdentifier: COMPANY_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-company-created',
description:
"Computes a company's last contact from its people when the company is created.",
timeoutSeconds: BATCH_HANDLER_TIMEOUT_SECONDS,
databaseEventTriggerSettings: {
eventName: 'company.created',
batchMode: true,
},
handler,
});

View File

@@ -0,0 +1,55 @@
import {
defineLogicFunction,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk/define';
import { type DatabaseEventBatchPayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { BATCH_HANDLER_TIMEOUT_SECONDS } from 'src/constants/batch-handler-timeout-seconds';
import { EMAIL_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import {
applyEmailInteractions,
type MessageParticipantLink,
} from 'src/utils/apply-email-interactions';
type MessageParticipantUpdate = {
personId?: string | null;
messageId?: string | null;
};
const handler = async (
batch: DatabaseEventBatchPayload<
ObjectRecordUpdateEvent<MessageParticipantUpdate>
>,
): Promise<void> => {
const linkByKey = new Map<string, MessageParticipantLink>();
for (const event of batch.events) {
const personId = event.properties.after.personId;
const messageId = event.properties.after.messageId;
if (personId && messageId) {
linkByKey.set(`${personId}:${messageId}`, { personId, messageId });
}
}
if (linkByKey.size === 0) {
return;
}
await applyEmailInteractions(new CoreApiClient(), [...linkByKey.values()]);
};
export default defineLogicFunction({
universalIdentifier: EMAIL_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-email-interaction',
description:
"Updates a person's last-contacted fields, and the last contact on their company and opportunities, when a new email participant is created.",
timeoutSeconds: BATCH_HANDLER_TIMEOUT_SECONDS,
databaseEventTriggerSettings: {
eventName: 'messageParticipant.updated',
updatedFields: ['personId'],
batchMode: true,
},
handler,
});

View File

@@ -0,0 +1,47 @@
import {
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { type DatabaseEventBatchPayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { BATCH_HANDLER_TIMEOUT_SECONDS } from 'src/constants/batch-handler-timeout-seconds';
import { OPPORTUNITY_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { recomputeOpportunitiesLastContact } from 'src/utils/recompute-opportunity-last-contact';
type OpportunityCreate = { id?: string | null };
const handler = async (
batch: DatabaseEventBatchPayload<ObjectRecordCreateEvent<OpportunityCreate>>,
): Promise<void> => {
const opportunityIds = new Set<string>();
for (const event of batch.events) {
const opportunityId = event.properties.after.id ?? event.recordId;
if (opportunityId) {
opportunityIds.add(opportunityId);
}
}
if (opportunityIds.size === 0) {
return;
}
await recomputeOpportunitiesLastContact(new CoreApiClient(), [
...opportunityIds,
]);
};
export default defineLogicFunction({
universalIdentifier: OPPORTUNITY_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-opportunity-created',
description:
"Computes an opportunity's last contact from its point of contact when the opportunity is created.",
timeoutSeconds: BATCH_HANDLER_TIMEOUT_SECONDS,
databaseEventTriggerSettings: {
eventName: 'opportunity.created',
batchMode: true,
},
handler,
});

View File

@@ -0,0 +1,48 @@
import {
defineLogicFunction,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk/define';
import { type DatabaseEventBatchPayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { BATCH_HANDLER_TIMEOUT_SECONDS } from 'src/constants/batch-handler-timeout-seconds';
import { OPPORTUNITY_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { recomputeOpportunitiesLastContact } from 'src/utils/recompute-opportunity-last-contact';
type OpportunityUpdate = { id?: string | null };
const handler = async (
batch: DatabaseEventBatchPayload<ObjectRecordUpdateEvent<OpportunityUpdate>>,
): Promise<void> => {
const opportunityIds = new Set<string>();
for (const event of batch.events) {
const opportunityId = event.properties.after?.id ?? event.recordId;
if (opportunityId) {
opportunityIds.add(opportunityId);
}
}
if (opportunityIds.size === 0) {
return;
}
await recomputeOpportunitiesLastContact(new CoreApiClient(), [
...opportunityIds,
]);
};
export default defineLogicFunction({
universalIdentifier: OPPORTUNITY_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-opportunity-updated',
description:
"Recomputes an opportunity's last contact from its point of contact when the point of contact changes.",
timeoutSeconds: BATCH_HANDLER_TIMEOUT_SECONDS,
databaseEventTriggerSettings: {
eventName: 'opportunity.updated',
updatedFields: ['pointOfContactId'],
batchMode: true,
},
handler,
});

View File

@@ -0,0 +1,45 @@
import {
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { type DatabaseEventBatchPayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { BATCH_HANDLER_TIMEOUT_SECONDS } from 'src/constants/batch-handler-timeout-seconds';
import { PERSON_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { recomputeCompaniesLastContact } from 'src/utils/recompute-company-last-contact';
type PersonCreate = { companyId?: string | null };
const handler = async (
batch: DatabaseEventBatchPayload<ObjectRecordCreateEvent<PersonCreate>>,
): Promise<void> => {
const companyIds = new Set<string>();
for (const event of batch.events) {
const companyId = event.properties.after.companyId;
if (companyId) {
companyIds.add(companyId);
}
}
if (companyIds.size === 0) {
return;
}
await recomputeCompaniesLastContact(new CoreApiClient(), [...companyIds]);
};
export default defineLogicFunction({
universalIdentifier: PERSON_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-person-created',
description:
"Recomputes the company's last contact when a person is created with a company.",
timeoutSeconds: BATCH_HANDLER_TIMEOUT_SECONDS,
databaseEventTriggerSettings: {
eventName: 'person.created',
batchMode: true,
},
handler,
});

View File

@@ -0,0 +1,51 @@
import {
defineLogicFunction,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk/define';
import { type DatabaseEventBatchPayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { BATCH_HANDLER_TIMEOUT_SECONDS } from 'src/constants/batch-handler-timeout-seconds';
import { PERSON_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { recomputeCompaniesLastContact } from 'src/utils/recompute-company-last-contact';
type PersonUpdate = { companyId?: string | null };
const handler = async (
batch: DatabaseEventBatchPayload<ObjectRecordUpdateEvent<PersonUpdate>>,
): Promise<void> => {
const companyIds = new Set<string>();
// Both the person's former and current company can lose or gain their most
// recent contact when the person moves.
for (const event of batch.events) {
for (const companyId of [
event.properties.before?.companyId,
event.properties.after?.companyId,
]) {
if (companyId) {
companyIds.add(companyId);
}
}
}
if (companyIds.size === 0) {
return;
}
await recomputeCompaniesLastContact(new CoreApiClient(), [...companyIds]);
};
export default defineLogicFunction({
universalIdentifier: PERSON_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-person-updated',
description:
"Recomputes the former and current company's last contact when a person's company changes.",
timeoutSeconds: BATCH_HANDLER_TIMEOUT_SECONDS,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['companyId'],
batchMode: true,
},
handler,
});

View File

@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CoreApiClient } from 'twenty-client-sdk/core';
import { applyMeetingInteractions } from 'src/utils/apply-meeting-interactions';
const PERSON_ID = '11111111-1111-1111-1111-111111111111';
const OTHER_PERSON_ID = '22222222-2222-2222-2222-222222222222';
const MEMBER_ID = '33333333-3333-3333-3333-333333333333';
const CALENDAR_EVENT_ID = '44444444-4444-4444-4444-444444444444';
const OLDER_CALENDAR_EVENT_ID = '55555555-5555-5555-5555-555555555555';
const NOW = '2026-06-12T12:00:00.000Z';
const PAST_EVENT_STARTS_AT = '2026-06-10T09:00:00.000Z';
const OLDER_EVENT_STARTS_AT = '2026-06-01T09:00:00.000Z';
const FUTURE_EVENT_STARTS_AT = '2026-06-20T09:00:00.000Z';
type Page = {
edges: { node: Record<string, unknown> }[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
const buildPage = (nodes: Record<string, unknown>[]): Page => ({
edges: nodes.map((node) => ({ node })),
pageInfo: { hasNextPage: false, endCursor: null },
});
const buildClient = (participants: Record<string, unknown>[] = []) => {
const queryMock = vi.fn().mockImplementation((query) => {
if (query.calendarEventParticipants) {
return Promise.resolve({
calendarEventParticipants: buildPage(participants),
});
}
if (query.person) {
return Promise.resolve({ person: null });
}
if (query.people) {
return Promise.resolve({ people: buildPage([]) });
}
return Promise.resolve({ opportunities: buildPage([]) });
});
const mutationMock = vi
.fn()
.mockResolvedValue({ updatePeople: [{ id: 'updated' }] });
return {
client: {
query: queryMock,
mutation: mutationMock,
} as unknown as CoreApiClient,
queryMock,
mutationMock,
};
};
const buildParticipant = ({
calendarEventId,
startsAt,
isCanceled = false,
isOrganizer = null,
workspaceMemberId = null,
}: {
calendarEventId: string;
startsAt: string;
isCanceled?: boolean;
isOrganizer?: boolean | null;
workspaceMemberId?: string | null;
}) => ({
calendarEventId,
isOrganizer,
workspaceMemberId,
calendarEvent: { startsAt, isCanceled },
});
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(NOW));
});
afterEach(() => {
vi.useRealTimers();
});
describe('applyMeetingInteractions', () => {
it('resolves the whole batch through one participants query', async () => {
const { client, queryMock } = buildClient();
await applyMeetingInteractions(client, [
{ personId: PERSON_ID, calendarEventId: CALENDAR_EVENT_ID },
{ personId: OTHER_PERSON_ID, calendarEventId: OLDER_CALENDAR_EVENT_ID },
]);
expect(queryMock).toHaveBeenCalledTimes(1);
expect(
queryMock.mock.calls[0][0].calendarEventParticipants.__args.filter,
).toEqual({
calendarEventId: { in: [CALENDAR_EVENT_ID, OLDER_CALENDAR_EVENT_ID] },
});
});
it('sets lastContactAt, the organizer member and the calendarEvent item', async () => {
const { client, mutationMock } = buildClient([
buildParticipant({
calendarEventId: CALENDAR_EVENT_ID,
startsAt: PAST_EVENT_STARTS_AT,
workspaceMemberId: '66666666-6666-6666-6666-666666666666',
}),
buildParticipant({
calendarEventId: CALENDAR_EVENT_ID,
startsAt: PAST_EVENT_STARTS_AT,
isOrganizer: true,
workspaceMemberId: MEMBER_ID,
}),
]);
await applyMeetingInteractions(client, [
{ personId: PERSON_ID, calendarEventId: CALENDAR_EVENT_ID },
]);
expect(mutationMock.mock.calls[0][0].updatePeople.__args.data).toEqual({
lastContactAt: PAST_EVENT_STARTS_AT,
lastContactById: MEMBER_ID,
lastContactItemCalendarEventId: CALENDAR_EVENT_ID,
lastContactItemMessageId: null,
lastOutboundAt: PAST_EVENT_STARTS_AT,
lastInboundAt: PAST_EVENT_STARTS_AT,
lastMeetingId: CALENDAR_EVENT_ID,
});
});
it('keeps only the most recent meeting when a person attended several in the batch', async () => {
const { client, mutationMock } = buildClient([
buildParticipant({
calendarEventId: OLDER_CALENDAR_EVENT_ID,
startsAt: OLDER_EVENT_STARTS_AT,
}),
buildParticipant({
calendarEventId: CALENDAR_EVENT_ID,
startsAt: PAST_EVENT_STARTS_AT,
}),
]);
await applyMeetingInteractions(client, [
{ personId: PERSON_ID, calendarEventId: OLDER_CALENDAR_EVENT_ID },
{ personId: PERSON_ID, calendarEventId: CALENDAR_EVENT_ID },
]);
const personUpdates = mutationMock.mock.calls.filter(
([mutation]) => mutation.updatePeople,
);
expect(personUpdates).toHaveLength(1);
expect(personUpdates[0][0].updatePeople.__args.data).toMatchObject({
lastContactAt: PAST_EVENT_STARTS_AT,
lastContactItemCalendarEventId: CALENDAR_EVENT_ID,
lastMeetingId: CALENDAR_EVENT_ID,
});
});
it('skips a meeting that has not started yet', async () => {
const { client, mutationMock } = buildClient([
buildParticipant({
calendarEventId: CALENDAR_EVENT_ID,
startsAt: FUTURE_EVENT_STARTS_AT,
}),
]);
await applyMeetingInteractions(client, [
{ personId: PERSON_ID, calendarEventId: CALENDAR_EVENT_ID },
]);
expect(mutationMock).not.toHaveBeenCalled();
});
it('skips a canceled meeting', async () => {
const { client, mutationMock } = buildClient([
buildParticipant({
calendarEventId: CALENDAR_EVENT_ID,
startsAt: PAST_EVENT_STARTS_AT,
isCanceled: true,
}),
]);
await applyMeetingInteractions(client, [
{ personId: PERSON_ID, calendarEventId: CALENDAR_EVENT_ID },
]);
expect(mutationMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { executeWithRetry } from 'src/utils/execute-with-retry';
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(0);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('executeWithRetry', () => {
it('returns the result on first success', async () => {
const execute = vi.fn().mockResolvedValue('ok');
await expect(executeWithRetry(execute)).resolves.toBe('ok');
expect(execute).toHaveBeenCalledTimes(1);
});
it('retries rate-limited requests until they succeed', async () => {
const execute = vi
.fn()
.mockRejectedValueOnce(new Error('Too Many Requests: error code 1015'))
.mockResolvedValue('ok');
const promise = executeWithRetry(execute);
await vi.advanceTimersByTimeAsync(2_000);
await expect(promise).resolves.toBe('ok');
expect(execute).toHaveBeenCalledTimes(2);
});
it('does not retry non-retryable errors', async () => {
const execute = vi
.fn()
.mockRejectedValue(new Error('Bad Request: invalid data'));
await expect(executeWithRetry(execute)).rejects.toThrow('Bad Request');
expect(execute).toHaveBeenCalledTimes(1);
});
it('gives up after exhausting retries', async () => {
const execute = vi
.fn()
.mockRejectedValue(new Error('Gateway time-out: origin overloaded'));
const promise = executeWithRetry(execute);
const assertion = expect(promise).rejects.toThrow('Gateway time-out');
await vi.advanceTimersByTimeAsync(60_000);
await assertion;
expect(execute).toHaveBeenCalledTimes(5);
});
it('waits for the server-provided retry_after when it exceeds the backoff', async () => {
const execute = vi
.fn()
.mockRejectedValueOnce(
new Error('Gateway time-out: {"retryable": true, "retry_after": 10}'),
)
.mockResolvedValue('ok');
const promise = executeWithRetry(execute);
await vi.advanceTimersByTimeAsync(9_000);
expect(execute).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_000);
await expect(promise).resolves.toBe('ok');
expect(execute).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { pickContactTeamMemberId } from 'src/utils/pick-contact-team-member';
const MEMBER_A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const MEMBER_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
describe('pickContactTeamMemberId', () => {
it('prefers the from participant for email', () => {
const result = pickContactTeamMemberId(
[
{ role: 'TO', workspaceMemberId: MEMBER_A },
{ role: 'FROM', workspaceMemberId: MEMBER_B },
],
{ role: 'FROM' },
);
expect(result).toBe(MEMBER_B);
});
it('falls back to the first member when no from member exists', () => {
const result = pickContactTeamMemberId(
[
{ role: 'TO', workspaceMemberId: null },
{ role: 'CC', workspaceMemberId: MEMBER_A },
],
{ role: 'FROM' },
);
expect(result).toBe(MEMBER_A);
});
it('prefers the organizer for calendar', () => {
const result = pickContactTeamMemberId(
[
{ isOrganizer: false, workspaceMemberId: MEMBER_A },
{ isOrganizer: true, workspaceMemberId: MEMBER_B },
],
{ isOrganizer: true },
);
expect(result).toBe(MEMBER_B);
});
it('returns null when no participant is a workspace member', () => {
const result = pickContactTeamMemberId(
[{ role: 'TO', workspaceMemberId: null }],
{ role: 'FROM' },
);
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { recomputeCompanyLastContact } from 'src/utils/recompute-company-last-contact';
const COMPANY_ID = '11111111-1111-1111-1111-111111111111';
const CALENDAR_EVENT_ID = '44444444-4444-4444-4444-444444444444';
const OCCURRED_AT = '2026-06-10T09:00:00.000Z';
type Client = {
query: ReturnType<typeof vi.fn>;
mutation: ReturnType<typeof vi.fn>;
};
let client: Client;
beforeEach(() => {
client = {
query: vi.fn(),
mutation: vi.fn().mockResolvedValue({}),
};
});
describe('recomputeCompanyLastContact', () => {
it('mirrors the most recent contact among the company people', async () => {
client.query.mockResolvedValueOnce({
people: {
edges: [
{
node: {
lastContactAt: OCCURRED_AT,
lastContactItemMessage: null,
lastContactItemCalendarEvent: { id: CALENDAR_EVENT_ID },
},
},
],
},
});
await recomputeCompanyLastContact(client as never, COMPANY_ID);
const args = client.mutation.mock.calls[0][0].updateCompany.__args;
expect(args.id).toBe(COMPANY_ID);
expect(args.data).toEqual({
lastContactAt: OCCURRED_AT,
lastContactItemMessageId: null,
lastContactItemCalendarEventId: CALENDAR_EVENT_ID,
});
expect(client.query.mock.calls[0][0].people.__args.filter).toEqual({
companyId: { eq: COMPANY_ID },
lastContactAt: { is: 'NOT_NULL' },
});
});
it('clears the company last contact when no person has a contact', async () => {
client.query.mockResolvedValueOnce({ people: { edges: [] } });
await recomputeCompanyLastContact(client as never, COMPANY_ID);
expect(client.mutation.mock.calls[0][0].updateCompany.__args.data).toEqual({
lastContactAt: null,
lastContactItemMessageId: null,
lastContactItemCalendarEventId: null,
});
});
});

View File

@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { recomputeOpportunitiesLastContact } from 'src/utils/recompute-opportunity-last-contact';
const OPPORTUNITY_ID = '11111111-1111-1111-1111-111111111111';
const OTHER_OPPORTUNITY_ID = '44444444-4444-4444-4444-444444444444';
const PERSON_ID = '22222222-2222-2222-2222-222222222222';
const MESSAGE_ID = '33333333-3333-3333-3333-333333333333';
const OCCURRED_AT = '2026-06-10T09:00:00.000Z';
type Client = {
query: ReturnType<typeof vi.fn>;
mutation: ReturnType<typeof vi.fn>;
};
const buildPage = (nodes: Record<string, unknown>[]) => ({
edges: nodes.map((node) => ({ node })),
pageInfo: { hasNextPage: false, endCursor: null },
});
const buildClient = ({
opportunities = [],
people = [],
}: {
opportunities?: Record<string, unknown>[];
people?: Record<string, unknown>[];
}): Client => ({
query: vi.fn().mockImplementation((query) =>
query.opportunities
? Promise.resolve({ opportunities: buildPage(opportunities) })
: Promise.resolve({ people: buildPage(people) }),
),
mutation: vi.fn().mockResolvedValue({}),
});
let client: Client;
beforeEach(() => {
client = buildClient({});
});
describe('recomputeOpportunitiesLastContact', () => {
it('mirrors the point of contact last contact onto the opportunity', async () => {
client = buildClient({
opportunities: [{ id: OPPORTUNITY_ID, pointOfContactId: PERSON_ID }],
people: [
{
id: PERSON_ID,
lastContactAt: OCCURRED_AT,
lastContactItemMessage: { id: MESSAGE_ID },
lastContactItemCalendarEvent: null,
},
],
});
await recomputeOpportunitiesLastContact(client as never, [OPPORTUNITY_ID]);
expect(client.mutation.mock.calls[0][0].updateOpportunity.__args).toEqual({
id: OPPORTUNITY_ID,
data: {
lastContactAt: OCCURRED_AT,
lastContactItemMessageId: MESSAGE_ID,
lastContactItemCalendarEventId: null,
},
});
});
it('clears the opportunity last contact when there is no point of contact', async () => {
client = buildClient({
opportunities: [{ id: OPPORTUNITY_ID, pointOfContactId: null }],
});
await recomputeOpportunitiesLastContact(client as never, [OPPORTUNITY_ID]);
expect(client.query).toHaveBeenCalledTimes(1);
expect(
client.mutation.mock.calls[0][0].updateOpportunity.__args.data,
).toEqual({
lastContactAt: null,
lastContactItemMessageId: null,
lastContactItemCalendarEventId: null,
});
});
it('resolves a whole batch with one opportunity query and one people query', async () => {
client = buildClient({
opportunities: [
{ id: OPPORTUNITY_ID, pointOfContactId: PERSON_ID },
{ id: OTHER_OPPORTUNITY_ID, pointOfContactId: PERSON_ID },
],
people: [
{
id: PERSON_ID,
lastContactAt: OCCURRED_AT,
lastContactItemMessage: { id: MESSAGE_ID },
lastContactItemCalendarEvent: null,
},
],
});
await recomputeOpportunitiesLastContact(client as never, [
OPPORTUNITY_ID,
OTHER_OPPORTUNITY_ID,
]);
expect(client.query).toHaveBeenCalledTimes(2);
expect(
client.mutation.mock.calls.map(
([mutation]) => mutation.updateOpportunity.__args.id,
),
).toEqual([OPPORTUNITY_ID, OTHER_OPPORTUNITY_ID]);
});
});

View File

@@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { updateRelatedLastContactForPeople } from 'src/utils/update-related-last-contact';
const PERSON_ID = '11111111-1111-1111-1111-111111111111';
const OTHER_PERSON_ID = '44444444-4444-4444-4444-444444444444';
const MESSAGE_ID = '22222222-2222-2222-2222-222222222222';
const OLDER_MESSAGE_ID = '55555555-5555-5555-5555-555555555555';
const CALENDAR_EVENT_ID = '66666666-6666-6666-6666-666666666666';
const COMPANY_ID = '33333333-3333-3333-3333-333333333333';
const OCCURRED_AT = '2026-06-10T09:00:00.000Z';
const OLDER_OCCURRED_AT = '2026-06-01T09:00:00.000Z';
type Client = {
query: ReturnType<typeof vi.fn>;
mutation: ReturnType<typeof vi.fn>;
};
const buildPage = (nodes: Record<string, unknown>[]) => ({
edges: nodes.map((node) => ({ node })),
pageInfo: { hasNextPage: false, endCursor: null },
});
const buildClient = ({
people = [],
opportunities = [],
}: {
people?: Record<string, unknown>[];
opportunities?: Record<string, unknown>[];
}): Client => ({
query: vi.fn().mockImplementation((query) =>
query.people
? Promise.resolve({ people: buildPage(people) })
: Promise.resolve({ opportunities: buildPage(opportunities) }),
),
mutation: vi.fn().mockResolvedValue({}),
});
let client: Client;
beforeEach(() => {
client = buildClient({
people: [{ id: PERSON_ID, companyId: COMPANY_ID }],
opportunities: [{ id: 'opportunity-1', pointOfContactId: PERSON_ID }],
});
});
const findMutation = (name: string) =>
client.mutation.mock.calls.find((call) => call[0][name])?.[0][name];
describe('updateRelatedLastContactForPeople', () => {
it('updates the company and point-of-contact opportunities for an email', async () => {
await updateRelatedLastContactForPeople(
client as never,
new Map([
[
PERSON_ID,
{ occurredAt: OCCURRED_AT, itemId: MESSAGE_ID, kind: 'email' as const },
],
]),
);
const expectedData = {
lastContactAt: OCCURRED_AT,
lastContactItemMessageId: MESSAGE_ID,
lastContactItemCalendarEventId: null,
};
expect(findMutation('updateCompanies').__args.data).toEqual(expectedData);
expect(findMutation('updateCompanies').__args.filter.and[0]).toEqual({
id: { eq: COMPANY_ID },
});
expect(findMutation('updateOpportunities').__args.data).toEqual(
expectedData,
);
expect(findMutation('updateOpportunities').__args.filter.and[0]).toEqual({
pointOfContactId: { eq: PERSON_ID },
});
});
it('sets the calendar event item for a meeting', async () => {
await updateRelatedLastContactForPeople(
client as never,
new Map([
[
PERSON_ID,
{
occurredAt: OCCURRED_AT,
itemId: CALENDAR_EVENT_ID,
kind: 'meeting' as const,
},
],
]),
);
expect(findMutation('updateCompanies').__args.data).toEqual({
lastContactAt: OCCURRED_AT,
lastContactItemMessageId: null,
lastContactItemCalendarEventId: CALENDAR_EVENT_ID,
});
});
it('only guards against newer contacts', async () => {
await updateRelatedLastContactForPeople(
client as never,
new Map([
[
PERSON_ID,
{ occurredAt: OCCURRED_AT, itemId: MESSAGE_ID, kind: 'email' as const },
],
]),
);
const expectedGuard = {
or: [
{ lastContactAt: { is: 'NULL' } },
{ lastContactAt: { lt: OCCURRED_AT } },
],
};
expect(findMutation('updateCompanies').__args.filter.and[1]).toEqual(
expectedGuard,
);
expect(findMutation('updateOpportunities').__args.filter.and[1]).toEqual(
expectedGuard,
);
});
it('skips the company update when the person has no company but still updates opportunities', async () => {
client = buildClient({
people: [{ id: PERSON_ID, companyId: null }],
opportunities: [{ id: 'opportunity-1', pointOfContactId: PERSON_ID }],
});
await updateRelatedLastContactForPeople(
client as never,
new Map([
[
PERSON_ID,
{ occurredAt: OCCURRED_AT, itemId: MESSAGE_ID, kind: 'email' as const },
],
]),
);
expect(findMutation('updateCompanies')).toBeUndefined();
expect(findMutation('updateOpportunities').__args.filter.and[0]).toEqual({
pointOfContactId: { eq: PERSON_ID },
});
});
it('updates a shared company once, with the most recent contact of its people', async () => {
client = buildClient({
people: [
{ id: PERSON_ID, companyId: COMPANY_ID },
{ id: OTHER_PERSON_ID, companyId: COMPANY_ID },
],
});
await updateRelatedLastContactForPeople(
client as never,
new Map([
[
PERSON_ID,
{
occurredAt: OLDER_OCCURRED_AT,
itemId: OLDER_MESSAGE_ID,
kind: 'email' as const,
},
],
[
OTHER_PERSON_ID,
{ occurredAt: OCCURRED_AT, itemId: MESSAGE_ID, kind: 'email' as const },
],
]),
);
const companyUpdates = client.mutation.mock.calls.filter(
([mutation]) => mutation.updateCompanies,
);
expect(companyUpdates).toHaveLength(1);
expect(companyUpdates[0][0].updateCompanies.__args.data).toEqual({
lastContactAt: OCCURRED_AT,
lastContactItemMessageId: MESSAGE_ID,
lastContactItemCalendarEventId: null,
});
});
});

View File

@@ -0,0 +1,75 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { collectMessageInteractions } from 'src/utils/collect-message-interactions';
import {
type Interaction,
pickLatestInteraction,
updatePersonForInteractions,
} from 'src/utils/update-person-last-contact';
import {
type RelatedInteraction,
updateRelatedLastContactForPeople,
} from 'src/utils/update-related-last-contact';
export type MessageParticipantLink = {
personId: string;
messageId: string;
};
export const applyEmailInteractions = async (
client: CoreApiClient,
links: MessageParticipantLink[],
): Promise<void> => {
const messageIds = [...new Set(links.map((link) => link.messageId))];
if (messageIds.length === 0) {
return;
}
const interactionByMessageId = await collectMessageInteractions(
client,
messageIds,
);
const interactionsByPersonId = new Map<string, Interaction[]>();
for (const { personId, messageId } of links) {
const message = interactionByMessageId.get(messageId);
if (!message) {
continue;
}
const interaction: Interaction = {
kind: 'email',
occurredAt: message.receivedAt,
itemId: messageId,
workspaceMemberId: message.workspaceMemberId,
direction: message.direction,
};
const interactions = interactionsByPersonId.get(personId);
if (interactions) {
interactions.push(interaction);
} else {
interactionsByPersonId.set(personId, [interaction]);
}
}
const contactByPersonId = new Map<string, RelatedInteraction>();
for (const [personId, interactions] of interactionsByPersonId) {
await updatePersonForInteractions(client, personId, interactions);
const latest = pickLatestInteraction(interactions);
if (latest) {
contactByPersonId.set(personId, {
occurredAt: latest.occurredAt,
itemId: latest.itemId,
kind: 'email',
});
}
}
await updateRelatedLastContactForPeople(client, contactByPersonId);
};

View File

@@ -0,0 +1,76 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { collectCalendarEventInteractions } from 'src/utils/collect-calendar-event-interactions';
import {
type Interaction,
pickLatestInteraction,
updatePersonForInteractions,
} from 'src/utils/update-person-last-contact';
import {
type RelatedInteraction,
updateRelatedLastContactForPeople,
} from 'src/utils/update-related-last-contact';
export type CalendarEventParticipantLink = {
personId: string;
calendarEventId: string;
};
export const applyMeetingInteractions = async (
client: CoreApiClient,
links: CalendarEventParticipantLink[],
): Promise<void> => {
const calendarEventIds = [
...new Set(links.map((link) => link.calendarEventId)),
];
if (calendarEventIds.length === 0) {
return;
}
const interactionByCalendarEventId = await collectCalendarEventInteractions(
client,
calendarEventIds,
);
const interactionsByPersonId = new Map<string, Interaction[]>();
for (const { personId, calendarEventId } of links) {
const calendarEvent = interactionByCalendarEventId.get(calendarEventId);
if (!calendarEvent) {
continue;
}
const interaction: Interaction = {
kind: 'meeting',
occurredAt: calendarEvent.startsAt,
itemId: calendarEventId,
workspaceMemberId: calendarEvent.workspaceMemberId,
};
const interactions = interactionsByPersonId.get(personId);
if (interactions) {
interactions.push(interaction);
} else {
interactionsByPersonId.set(personId, [interaction]);
}
}
const contactByPersonId = new Map<string, RelatedInteraction>();
for (const [personId, interactions] of interactionsByPersonId) {
await updatePersonForInteractions(client, personId, interactions);
const latest = pickLatestInteraction(interactions);
if (latest) {
contactByPersonId.set(personId, {
occurredAt: latest.occurredAt,
itemId: latest.itemId,
kind: 'meeting',
});
}
}
await updateRelatedLastContactForPeople(client, contactByPersonId);
};

View File

@@ -0,0 +1,17 @@
// A total order over createdAt then id keeps offset windows stable while the
// backfill runs: newly created records sort to the tail, so the batch each job
// owns does not shift under it.
const BACKFILL_ORDER_BY = [
{ createdAt: 'AscNullsFirst' },
{ id: 'AscNullsFirst' },
];
// Query args selecting the record window a batch job owns.
export const buildBackfillBatchArgs = (
batchId: number,
batchSize: number,
): { first: number; offset: number; orderBy: typeof BACKFILL_ORDER_BY } => ({
first: batchSize,
offset: batchId * batchSize,
orderBy: BACKFILL_ORDER_BY,
});

View File

@@ -0,0 +1,31 @@
import {
BACKFILL_BATCH_SIZE_ENV_VAR_NAME,
BACKFILL_SLEEP_MS_ENV_VAR_NAME,
DEFAULT_BACKFILL_BATCH_SIZE,
DEFAULT_BACKFILL_SLEEP_MS,
} from 'src/constants/backfill';
// Application and server variables are injected into process.env on every
// execution.
const readPositiveInteger = (
envVarName: string,
fallback: number,
): number => {
const rawValue = process.env[envVarName];
if (rawValue === undefined || rawValue.trim().length === 0) {
return fallback;
}
const parsedValue = Number(rawValue);
return Number.isInteger(parsedValue) && parsedValue > 0
? parsedValue
: fallback;
};
export const getBackfillBatchSize = (): number =>
readPositiveInteger(BACKFILL_BATCH_SIZE_ENV_VAR_NAME, DEFAULT_BACKFILL_BATCH_SIZE);
export const getBackfillSleepMs = (): number =>
readPositiveInteger(BACKFILL_SLEEP_MS_ENV_VAR_NAME, DEFAULT_BACKFILL_SLEEP_MS);

View File

@@ -0,0 +1,9 @@
export const chunk = <TItem>(items: TItem[], size: number): TItem[][] => {
const chunks: TItem[][] = [];
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size));
}
return chunks;
};

View File

@@ -0,0 +1,106 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { chunk } from 'src/utils/chunk';
import { executeWithRetry } from 'src/utils/execute-with-retry';
import {
pickContactTeamMemberId,
type Participant,
} from 'src/utils/pick-contact-team-member';
const PAGE_SIZE = 200;
export type CalendarEventInteraction = {
startsAt: string;
workspaceMemberId: string | null;
};
type CalendarEventParticipantNode = Participant & {
calendarEventId?: string | null;
calendarEvent?: {
startsAt: string | null;
isCanceled: boolean | null;
} | null;
};
export const collectCalendarEventInteractions = async (
client: CoreApiClient,
calendarEventIds: string[],
): Promise<Map<string, CalendarEventInteraction>> => {
const participantsByCalendarEventId = new Map<
string,
CalendarEventParticipantNode[]
>();
for (const ids of chunk(calendarEventIds, PAGE_SIZE)) {
let after: string | undefined;
do {
const { calendarEventParticipants } = await executeWithRetry(() =>
client.query({
calendarEventParticipants: {
__args: {
filter: { calendarEventId: { in: ids } },
first: PAGE_SIZE,
after,
},
edges: {
node: {
calendarEventId: true,
isOrganizer: true,
workspaceMemberId: true,
calendarEvent: { startsAt: true, isCanceled: true },
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
}),
);
for (const edge of calendarEventParticipants?.edges ?? []) {
const node = edge.node as CalendarEventParticipantNode;
if (!node.calendarEventId) {
continue;
}
const participants = participantsByCalendarEventId.get(
node.calendarEventId,
);
if (participants) {
participants.push(node);
} else {
participantsByCalendarEventId.set(node.calendarEventId, [node]);
}
}
after = calendarEventParticipants?.pageInfo.hasNextPage
? (calendarEventParticipants.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
}
const now = new Date().toISOString();
const interactionByCalendarEventId = new Map<
string,
CalendarEventInteraction
>();
for (const [calendarEventId, participants] of participantsByCalendarEventId) {
const calendarEvent = participants[0]?.calendarEvent;
const startsAt = calendarEvent?.startsAt ?? null;
if (!startsAt || calendarEvent?.isCanceled || startsAt > now) {
continue;
}
interactionByCalendarEventId.set(calendarEventId, {
startsAt,
workspaceMemberId: pickContactTeamMemberId(participants, {
isOrganizer: true,
}),
});
}
return interactionByCalendarEventId;
};

View File

@@ -0,0 +1,100 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { chunk } from 'src/utils/chunk';
import { executeWithRetry } from 'src/utils/execute-with-retry';
import {
pickContactTeamMemberId,
type Participant,
} from 'src/utils/pick-contact-team-member';
import { type InteractionDirection } from 'src/utils/update-person-last-contact';
const PAGE_SIZE = 200;
export type MessageInteraction = {
receivedAt: string;
workspaceMemberId: string | null;
direction: InteractionDirection;
};
type MessageParticipantNode = Participant & {
messageId?: string | null;
message?: { receivedAt: string | null } | null;
};
export const collectMessageInteractions = async (
client: CoreApiClient,
messageIds: string[],
): Promise<Map<string, MessageInteraction>> => {
const participantsByMessageId = new Map<string, MessageParticipantNode[]>();
for (const ids of chunk(messageIds, PAGE_SIZE)) {
let after: string | undefined;
do {
const { messageParticipants } = await executeWithRetry(() =>
client.query({
messageParticipants: {
__args: {
filter: { messageId: { in: ids } },
first: PAGE_SIZE,
after,
},
edges: {
node: {
messageId: true,
role: true,
workspaceMemberId: true,
message: { receivedAt: true },
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
}),
);
for (const edge of messageParticipants?.edges ?? []) {
const node = edge.node as MessageParticipantNode;
if (!node.messageId) {
continue;
}
const participants = participantsByMessageId.get(node.messageId);
if (participants) {
participants.push(node);
} else {
participantsByMessageId.set(node.messageId, [node]);
}
}
after = messageParticipants?.pageInfo.hasNextPage
? (messageParticipants.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
}
const interactionByMessageId = new Map<string, MessageInteraction>();
for (const [messageId, participants] of participantsByMessageId) {
const receivedAt = participants[0]?.message?.receivedAt ?? null;
if (!receivedAt) {
continue;
}
const fromParticipant = participants.find(
(participant) => participant.role === 'FROM',
);
interactionByMessageId.set(messageId, {
receivedAt,
workspaceMemberId: pickContactTeamMemberId(participants, {
role: 'FROM',
}),
direction: fromParticipant?.workspaceMemberId ? 'outbound' : 'inbound',
});
}
return interactionByMessageId;
};

View File

@@ -0,0 +1,47 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { executeWithRetry } from 'src/utils/execute-with-retry';
const PAGE_SIZE = 200;
export const collectPeopleByCompany = async (
client: CoreApiClient,
companyIds: string[],
): Promise<Map<string, string[]>> => {
const peopleByCompanyId = new Map<string, string[]>();
let after: string | undefined;
do {
const { people } = await executeWithRetry(() =>
client.query({
people: {
__args: {
filter: { companyId: { in: companyIds } },
first: PAGE_SIZE,
after,
},
edges: { node: { id: true, companyId: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
}),
);
for (const edge of people?.edges ?? []) {
const { id, companyId } = edge.node;
if (id && companyId) {
const existing = peopleByCompanyId.get(companyId);
if (existing) {
existing.push(id);
} else {
peopleByCompanyId.set(companyId, [id]);
}
}
}
after = people?.pageInfo.hasNextPage
? (people.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
return peopleByCompanyId;
};

View File

@@ -0,0 +1,70 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { enqueueJob } from 'twenty-sdk/logic-function';
import {
type BackfillPhase,
BACKFILL_PHASE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS,
BACKFILL_PHASE_ORDER,
BACKFILL_PHASE_QUERY_FIELD,
} from 'src/constants/backfill';
import {
getBackfillBatchSize,
getBackfillSleepMs,
} from 'src/utils/backfill-settings';
import { executeWithRetry } from 'src/utils/execute-with-retry';
// enqueueJob rejects delays beyond 7 days.
const MAX_ENQUEUE_DELAY_MS = 7 * 24 * 60 * 60 * 1_000;
// Batch handlers are idempotent (they recompute from source and overwrite), so
// a batch that dies mid-run can safely be retried by the queue rather than
// leaving its records unbackfilled.
const BACKFILL_JOB_RETRY_LIMIT = 3;
type BackfillPhasePlan = { phase: BackfillPhase; count: number; batches: number };
const countPhaseRecords = async (
client: CoreApiClient,
phase: BackfillPhase,
): Promise<number> => {
const field = BACKFILL_PHASE_QUERY_FIELD[phase];
const result = await executeWithRetry(() =>
client.query({ [field]: { __args: { first: 1 }, totalCount: true } }),
);
return result?.[field]?.totalCount ?? 0;
};
// Counts every phase, then enqueues one job per record batch across all phases.
// Jobs are spaced by delayMs so thousands of batches do not all become eligible
// at once and overwhelm the API rate limiting.
export const enqueueBackfillJobs = async (
client: CoreApiClient,
): Promise<BackfillPhasePlan[]> => {
const batchSize = getBackfillBatchSize();
const sleepMs = getBackfillSleepMs();
const plans: BackfillPhasePlan[] = [];
let enqueuedCount = 0;
for (const phase of BACKFILL_PHASE_ORDER) {
const count = await countPhaseRecords(client, phase);
const batches = Math.ceil(count / batchSize);
for (let batchId = 0; batchId < batches; batchId++) {
await enqueueJob({
logicFunctionUniversalIdentifier:
BACKFILL_PHASE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIERS[phase],
payload: { batchId },
delayMs: Math.min(enqueuedCount * sleepMs, MAX_ENQUEUE_DELAY_MS),
retryLimit: BACKFILL_JOB_RETRY_LIMIT,
});
enqueuedCount++;
}
plans.push({ phase, count, batches });
}
return plans;
};

View File

@@ -0,0 +1,51 @@
const MAX_ATTEMPTS = 5;
const INITIAL_RETRY_DELAY_MS = 2_000;
const MAX_RETRY_DELAY_MS = 30_000;
const MAX_JITTER_MS = 1_000;
// The client SDK surfaces HTTP failures as plain Error messages built from the
// status text and raw response body, so retryability has to be detected from
// the message text. Covers rate limiting (429, Cloudflare 1015), transient
// gateway errors (502/503/504) and network-level failures.
const RETRYABLE_ERROR_PATTERN =
/\b(429|1015|too many requests|rate ?limit\w*|502|503|504|bad gateway|gateway time-?out|service unavailable|timed? ?out|fetch failed|econnreset|econnrefused|socket hang up)\b/i;
const sleep = (durationMs: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, durationMs));
const getErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const isRetryableError = (error: unknown): boolean =>
RETRYABLE_ERROR_PATTERN.test(getErrorMessage(error));
const parseRetryAfterMs = (error: unknown): number | undefined => {
const match = getErrorMessage(error).match(/"retry_after"\s*:\s*(\d+)/);
return match ? Number(match[1]) * 1_000 : undefined;
};
export const executeWithRetry = async <TResult>(
execute: () => TResult,
): Promise<Awaited<TResult>> => {
for (let attempt = 1; ; attempt += 1) {
try {
return await execute();
} catch (error) {
if (attempt >= MAX_ATTEMPTS || !isRetryableError(error)) {
throw error;
}
const backoffMs = Math.min(
INITIAL_RETRY_DELAY_MS * 2 ** (attempt - 1),
MAX_RETRY_DELAY_MS,
);
const retryAfterMs = Math.min(
parseRetryAfterMs(error) ?? 0,
MAX_RETRY_DELAY_MS,
);
const jitterMs = Math.random() * MAX_JITTER_MS;
await sleep(Math.max(backoffMs, retryAfterMs) + jitterMs);
}
}
};

View File

@@ -0,0 +1,398 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { chunk } from 'src/utils/chunk';
import { executeWithRetry } from 'src/utils/execute-with-retry';
const PAGE_SIZE = 200;
type EmailInteraction = {
personId: string;
messageId: string;
receivedAt: string;
};
type MeetingInteraction = {
personId: string;
calendarEventId: string;
startsAt: string;
};
type MessageMemberInfo = { ownerId: string; fromIsMember: boolean };
type ContactItem = { kind: 'email' | 'meeting'; id: string };
export type LastContact = { at: string; item: ContactItem };
export type PersonUpdateData = Record<string, string | null>;
export type PersonAgg = {
lastContactAt?: string;
lastContactById?: string | null;
item?: ContactItem;
lastOutboundAt?: string;
lastInboundAt?: string;
lastEmail?: { at: string; id: string };
lastMeeting?: { at: string; id: string };
};
const collectEmailInteractions = async (
client: CoreApiClient,
personIds: string[],
): Promise<EmailInteraction[]> => {
const interactions: EmailInteraction[] = [];
for (const ids of chunk(personIds, PAGE_SIZE)) {
let after: string | undefined;
do {
const { messageParticipants } = await executeWithRetry(() =>
client.query({
messageParticipants: {
__args: {
filter: { personId: { in: ids } },
first: PAGE_SIZE,
after,
},
edges: {
node: {
id: true,
personId: true,
message: { id: true, receivedAt: true },
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
}),
);
for (const edge of messageParticipants?.edges ?? []) {
const { personId, message } = edge.node;
if (personId && message?.id && message?.receivedAt) {
interactions.push({
personId,
messageId: message.id,
receivedAt: message.receivedAt,
});
}
}
after = messageParticipants?.pageInfo.hasNextPage
? (messageParticipants.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
}
return interactions;
};
const collectMeetingInteractions = async (
client: CoreApiClient,
personIds: string[],
): Promise<MeetingInteraction[]> => {
const now = new Date().toISOString();
const interactions: MeetingInteraction[] = [];
for (const ids of chunk(personIds, PAGE_SIZE)) {
let after: string | undefined;
do {
const { calendarEventParticipants } = await executeWithRetry(() =>
client.query({
calendarEventParticipants: {
__args: {
filter: { personId: { in: ids } },
first: PAGE_SIZE,
after,
},
edges: {
node: {
id: true,
personId: true,
calendarEvent: { id: true, startsAt: true, isCanceled: true },
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
}),
);
for (const edge of calendarEventParticipants?.edges ?? []) {
const { personId, calendarEvent } = edge.node;
if (
personId &&
calendarEvent?.id &&
calendarEvent?.startsAt &&
!calendarEvent.isCanceled &&
calendarEvent.startsAt <= now
) {
interactions.push({
personId,
calendarEventId: calendarEvent.id,
startsAt: calendarEvent.startsAt,
});
}
}
after = calendarEventParticipants?.pageInfo.hasNextPage
? (calendarEventParticipants.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
}
return interactions;
};
const collectMessageMemberInfo = async (
client: CoreApiClient,
messageIds: string[],
): Promise<Map<string, MessageMemberInfo>> => {
const infoByMessageId = new Map<string, MessageMemberInfo>();
for (const ids of chunk(messageIds, PAGE_SIZE)) {
let after: string | undefined;
do {
const { messageParticipants } = await executeWithRetry(() =>
client.query({
messageParticipants: {
__args: {
filter: {
messageId: { in: ids },
workspaceMemberId: { is: 'NOT_NULL' },
},
first: PAGE_SIZE,
after,
},
edges: {
node: { messageId: true, role: true, workspaceMemberId: true },
},
pageInfo: { hasNextPage: true, endCursor: true },
},
}),
);
for (const edge of messageParticipants?.edges ?? []) {
const { messageId, role, workspaceMemberId } = edge.node;
if (!messageId || !workspaceMemberId) {
continue;
}
const info = infoByMessageId.get(messageId) ?? {
ownerId: workspaceMemberId,
fromIsMember: false,
};
if (role === 'FROM') {
info.ownerId = workspaceMemberId;
info.fromIsMember = true;
}
infoByMessageId.set(messageId, info);
}
after = messageParticipants?.pageInfo.hasNextPage
? (messageParticipants.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
}
return infoByMessageId;
};
const collectCalendarOwners = async (
client: CoreApiClient,
calendarEventIds: string[],
): Promise<Map<string, string>> => {
const ownerByCalendarEventId = new Map<string, string>();
for (const ids of chunk(calendarEventIds, PAGE_SIZE)) {
let after: string | undefined;
do {
const { calendarEventParticipants } = await executeWithRetry(() =>
client.query({
calendarEventParticipants: {
__args: {
filter: {
calendarEventId: { in: ids },
workspaceMemberId: { is: 'NOT_NULL' },
},
first: PAGE_SIZE,
after,
},
edges: {
node: {
calendarEventId: true,
isOrganizer: true,
workspaceMemberId: true,
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
}),
);
for (const edge of calendarEventParticipants?.edges ?? []) {
const { calendarEventId, isOrganizer, workspaceMemberId } = edge.node;
if (
calendarEventId &&
workspaceMemberId &&
(!ownerByCalendarEventId.has(calendarEventId) || isOrganizer === true)
) {
ownerByCalendarEventId.set(calendarEventId, workspaceMemberId);
}
}
after = calendarEventParticipants?.pageInfo.hasNextPage
? (calendarEventParticipants.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
}
return ownerByCalendarEventId;
};
const foldEmail = (
agg: PersonAgg,
receivedAt: string,
messageId: string,
info: MessageMemberInfo | undefined,
): void => {
if (!agg.lastEmail || receivedAt > agg.lastEmail.at) {
agg.lastEmail = { at: receivedAt, id: messageId };
}
if (info?.fromIsMember) {
if (!agg.lastOutboundAt || receivedAt > agg.lastOutboundAt) {
agg.lastOutboundAt = receivedAt;
}
} else if (!agg.lastInboundAt || receivedAt > agg.lastInboundAt) {
agg.lastInboundAt = receivedAt;
}
if (!agg.lastContactAt || receivedAt > agg.lastContactAt) {
agg.lastContactAt = receivedAt;
agg.lastContactById = info?.ownerId ?? null;
agg.item = { kind: 'email', id: messageId };
}
};
const foldMeeting = (
agg: PersonAgg,
startsAt: string,
calendarEventId: string,
ownerId: string | null,
): void => {
if (!agg.lastMeeting || startsAt > agg.lastMeeting.at) {
agg.lastMeeting = { at: startsAt, id: calendarEventId };
}
if (!agg.lastOutboundAt || startsAt > agg.lastOutboundAt) {
agg.lastOutboundAt = startsAt;
}
if (!agg.lastInboundAt || startsAt > agg.lastInboundAt) {
agg.lastInboundAt = startsAt;
}
if (!agg.lastContactAt || startsAt > agg.lastContactAt) {
agg.lastContactAt = startsAt;
agg.lastContactById = ownerId;
agg.item = { kind: 'meeting', id: calendarEventId };
}
};
// Aggregates every email and meeting interaction of the given people into one
// last-contact snapshot per person, resolving the owning team member and the
// inbound/outbound direction from the message and calendar participants.
export const buildPersonAggregates = async (
client: CoreApiClient,
personIds: string[],
): Promise<Map<string, PersonAgg>> => {
const aggByPersonId = new Map<string, PersonAgg>();
if (personIds.length === 0) {
return aggByPersonId;
}
const [emails, meetings] = await Promise.all([
collectEmailInteractions(client, personIds),
collectMeetingInteractions(client, personIds),
]);
const messageIds = [...new Set(emails.map((email) => email.messageId))];
const calendarEventIds = [
...new Set(meetings.map((meeting) => meeting.calendarEventId)),
];
const [messageMemberInfo, calendarOwners] = await Promise.all([
collectMessageMemberInfo(client, messageIds),
collectCalendarOwners(client, calendarEventIds),
]);
const aggFor = (personId: string): PersonAgg => {
const existing = aggByPersonId.get(personId);
if (existing) {
return existing;
}
const created: PersonAgg = {};
aggByPersonId.set(personId, created);
return created;
};
for (const email of emails) {
foldEmail(
aggFor(email.personId),
email.receivedAt,
email.messageId,
messageMemberInfo.get(email.messageId),
);
}
for (const meeting of meetings) {
foldMeeting(
aggFor(meeting.personId),
meeting.startsAt,
meeting.calendarEventId,
calendarOwners.get(meeting.calendarEventId) ?? null,
);
}
return aggByPersonId;
};
export const pickPersonLastContact = (
agg: PersonAgg | undefined,
): LastContact | undefined =>
agg?.lastContactAt && agg.item
? { at: agg.lastContactAt, item: agg.item }
: undefined;
export const pickLatestLastContact = (
contacts: LastContact[],
): LastContact | undefined =>
contacts.reduce<LastContact | undefined>(
(latest, contact) =>
!latest || contact.at > latest.at ? contact : latest,
undefined,
);
export const buildPersonUpdateData = (agg: PersonAgg): PersonUpdateData => ({
...(agg.lastContactAt
? {
lastContactAt: agg.lastContactAt,
lastContactById: agg.lastContactById ?? null,
}
: {}),
...(agg.lastOutboundAt ? { lastOutboundAt: agg.lastOutboundAt } : {}),
...(agg.lastInboundAt ? { lastInboundAt: agg.lastInboundAt } : {}),
...(agg.lastEmail ? { lastEmailId: agg.lastEmail.id } : {}),
...(agg.lastMeeting ? { lastMeetingId: agg.lastMeeting.id } : {}),
...(agg.item?.kind === 'email'
? {
lastContactItemMessageId: agg.item.id,
lastContactItemCalendarEventId: null,
}
: agg.item?.kind === 'meeting'
? {
lastContactItemCalendarEventId: agg.item.id,
lastContactItemMessageId: null,
}
: {}),
});
export const buildRelatedUpdateData = ({
at,
item,
}: LastContact): PersonUpdateData => ({
lastContactAt: at,
lastContactItemMessageId: item.kind === 'email' ? item.id : null,
lastContactItemCalendarEventId: item.kind === 'meeting' ? item.id : null,
});

View File

@@ -0,0 +1,26 @@
export type Participant = {
workspaceMemberId?: string | null;
role?: string | null;
isOrganizer?: boolean | null;
};
export const pickContactTeamMemberId = (
participants: Participant[],
prefer: { role: 'FROM' } | { isOrganizer: true },
): string | null => {
const members = participants.filter((participant) =>
Boolean(participant.workspaceMemberId),
);
if (members.length === 0) {
return null;
}
const preferred = members.find((participant) =>
'role' in prefer
? participant.role === prefer.role
: participant.isOrganizer === true,
);
return (preferred ?? members[0]).workspaceMemberId ?? null;
};

View File

@@ -0,0 +1,68 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { executeWithRetry } from 'src/utils/execute-with-retry';
type PersonNode = {
lastContactAt?: string | null;
lastContactItemMessage?: { id: string } | null;
lastContactItemCalendarEvent?: { id: string } | null;
};
// A company's last contact mirrors the most recent contact of any of its people,
// so it must be recomputed whenever that set of people changes rather than only
// when an interaction happens.
export const recomputeCompanyLastContact = async (
client: CoreApiClient,
companyId: string,
): Promise<void> => {
const { people } = await executeWithRetry(() =>
client.query({
people: {
__args: {
filter: {
companyId: { eq: companyId },
lastContactAt: { is: 'NOT_NULL' },
},
orderBy: [{ lastContactAt: 'DescNullsLast' }],
first: 1,
},
edges: {
node: {
lastContactAt: true,
lastContactItemMessage: { id: true },
lastContactItemCalendarEvent: { id: true },
},
},
},
}),
);
const topPerson = (people?.edges?.[0]?.node as PersonNode | undefined) ?? {};
await executeWithRetry(() =>
client.mutation({
updateCompany: {
__args: {
id: companyId,
data: {
lastContactAt: topPerson.lastContactAt ?? null,
lastContactItemMessageId:
topPerson.lastContactItemMessage?.id ?? null,
lastContactItemCalendarEventId:
topPerson.lastContactItemCalendarEvent?.id ?? null,
},
},
id: true,
},
}),
);
};
export const recomputeCompaniesLastContact = async (
client: CoreApiClient,
companyIds: string[],
): Promise<void> => {
for (const companyId of companyIds) {
await recomputeCompanyLastContact(client, companyId);
}
};

View File

@@ -0,0 +1,153 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { chunk } from 'src/utils/chunk';
import { executeWithRetry } from 'src/utils/execute-with-retry';
const PAGE_SIZE = 200;
type LastContactData = Record<string, string | null>;
const EMPTY_LAST_CONTACT: LastContactData = {
lastContactAt: null,
lastContactItemMessageId: null,
lastContactItemCalendarEventId: null,
};
type PersonLastContact = {
lastContactAt?: string | null;
lastContactItemMessage?: { id: string } | null;
lastContactItemCalendarEvent?: { id: string } | null;
};
const collectPointOfContactIdByOpportunityId = async (
client: CoreApiClient,
opportunityIds: string[],
): Promise<Map<string, string | null>> => {
const pointOfContactIdByOpportunityId = new Map<string, string | null>();
for (const ids of chunk(opportunityIds, PAGE_SIZE)) {
let after: string | undefined;
do {
const { opportunities } = await executeWithRetry(() =>
client.query({
opportunities: {
__args: { filter: { id: { in: ids } }, first: PAGE_SIZE, after },
edges: { node: { id: true, pointOfContactId: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
}),
);
for (const edge of opportunities?.edges ?? []) {
const { id, pointOfContactId } = edge.node;
if (id) {
pointOfContactIdByOpportunityId.set(id, pointOfContactId ?? null);
}
}
after = opportunities?.pageInfo.hasNextPage
? (opportunities.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
}
return pointOfContactIdByOpportunityId;
};
const collectLastContactByPersonId = async (
client: CoreApiClient,
personIds: string[],
): Promise<Map<string, LastContactData>> => {
const lastContactByPersonId = new Map<string, LastContactData>();
for (const ids of chunk(personIds, PAGE_SIZE)) {
let after: string | undefined;
do {
const { people } = await executeWithRetry(() =>
client.query({
people: {
__args: { filter: { id: { in: ids } }, first: PAGE_SIZE, after },
edges: {
node: {
id: true,
lastContactAt: true,
lastContactItemMessage: { id: true },
lastContactItemCalendarEvent: { id: true },
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
}),
);
for (const edge of people?.edges ?? []) {
const person = edge.node as PersonLastContact & { id?: string | null };
if (!person.id) {
continue;
}
lastContactByPersonId.set(person.id, {
lastContactAt: person.lastContactAt ?? null,
lastContactItemMessageId: person.lastContactItemMessage?.id ?? null,
lastContactItemCalendarEventId:
person.lastContactItemCalendarEvent?.id ?? null,
});
}
after = people?.pageInfo.hasNextPage
? (people.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
}
return lastContactByPersonId;
};
// An opportunity's last contact mirrors its point of contact, so it must be
// recomputed whenever the opportunity is created or its point of contact changes,
// not only when an interaction happens.
export const recomputeOpportunitiesLastContact = async (
client: CoreApiClient,
opportunityIds: string[],
): Promise<void> => {
if (opportunityIds.length === 0) {
return;
}
const pointOfContactIdByOpportunityId =
await collectPointOfContactIdByOpportunityId(client, opportunityIds);
const personIds = [
...new Set(
[...pointOfContactIdByOpportunityId.values()].filter(
(pointOfContactId): pointOfContactId is string => !!pointOfContactId,
),
),
];
const lastContactByPersonId = await collectLastContactByPersonId(
client,
personIds,
);
for (const [
opportunityId,
pointOfContactId,
] of pointOfContactIdByOpportunityId) {
const data =
(pointOfContactId
? lastContactByPersonId.get(pointOfContactId)
: undefined) ?? EMPTY_LAST_CONTACT;
await executeWithRetry(() =>
client.mutation({
updateOpportunity: {
__args: { id: opportunityId, data },
id: true,
},
}),
);
}
};

View File

@@ -0,0 +1,186 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { executeWithRetry } from 'src/utils/execute-with-retry';
export type InteractionKind = 'email' | 'meeting';
export type InteractionDirection = 'outbound' | 'inbound';
export type Interaction = {
occurredAt: string;
itemId: string;
workspaceMemberId: string | null;
} & ({ kind: 'email'; direction: InteractionDirection } | { kind: 'meeting' });
const isNewer = (
candidate: string,
current: string | null | undefined,
): boolean => !current || current < candidate;
const touchesOutbound = (interaction: Interaction): boolean =>
interaction.kind === 'meeting' || interaction.direction === 'outbound';
const touchesInbound = (interaction: Interaction): boolean =>
interaction.kind === 'meeting' || interaction.direction === 'inbound';
const pickLatest = (
interactions: Interaction[],
matches: (interaction: Interaction) => boolean = () => true,
): Interaction | undefined =>
interactions.reduce<Interaction | undefined>(
(latest, interaction) =>
matches(interaction) &&
(!latest || interaction.occurredAt > latest.occurredAt)
? interaction
: latest,
undefined,
);
export const pickLatestInteraction = (
interactions: Interaction[],
): Interaction | undefined => pickLatest(interactions);
export const updatePersonForInteractions = async (
client: CoreApiClient,
personId: string,
interactions: Interaction[],
): Promise<void> => {
const latestContact = pickLatest(interactions);
if (!latestContact) {
return;
}
const { person } = await executeWithRetry(() =>
client.query({
person: {
__args: { filter: { id: { eq: personId } } },
id: true,
lastContactAt: true,
lastOutboundAt: true,
lastInboundAt: true,
lastEmail: { receivedAt: true },
lastMeeting: { startsAt: true },
},
}),
);
const current = (person ?? {}) as {
lastContactAt?: string | null;
lastOutboundAt?: string | null;
lastInboundAt?: string | null;
lastEmail?: { receivedAt: string | null } | null;
lastMeeting?: { startsAt: string | null } | null;
};
const data: Record<string, string | null> = {};
const occurredAt = latestContact.occurredAt;
if (isNewer(occurredAt, current.lastContactAt)) {
data.lastContactAt = occurredAt;
data.lastContactById = latestContact.workspaceMemberId ?? null;
if (latestContact.kind === 'email') {
data.lastContactItemMessageId = latestContact.itemId;
data.lastContactItemCalendarEventId = null;
} else {
data.lastContactItemCalendarEventId = latestContact.itemId;
data.lastContactItemMessageId = null;
}
}
const latestOutbound = pickLatest(interactions, touchesOutbound);
const latestInbound = pickLatest(interactions, touchesInbound);
const latestEmail = pickLatest(
interactions,
(interaction) => interaction.kind === 'email',
);
const latestMeeting = pickLatest(
interactions,
(interaction) => interaction.kind === 'meeting',
);
if (
latestOutbound &&
isNewer(latestOutbound.occurredAt, current.lastOutboundAt)
) {
data.lastOutboundAt = latestOutbound.occurredAt;
}
if (
latestInbound &&
isNewer(latestInbound.occurredAt, current.lastInboundAt)
) {
data.lastInboundAt = latestInbound.occurredAt;
}
if (
latestEmail &&
isNewer(latestEmail.occurredAt, current.lastEmail?.receivedAt)
) {
data.lastEmailId = latestEmail.itemId;
}
if (
latestMeeting &&
isNewer(latestMeeting.occurredAt, current.lastMeeting?.startsAt)
) {
data.lastMeetingId = latestMeeting.itemId;
}
if (Object.keys(data).length === 0) {
return;
}
if ('lastContactAt' in data) {
const { updatePeople } = await executeWithRetry(() =>
client.mutation({
updatePeople: {
__args: {
data,
filter: {
and: [
{ id: { eq: personId } },
{
or: [
{ lastContactAt: { is: 'NULL' } },
{ lastContactAt: { lt: occurredAt } },
],
},
],
},
},
id: true,
},
}),
);
if (Array.isArray(updatePeople) && updatePeople.length > 0) {
return;
}
const directionalData: Record<string, string | null> = { ...data };
delete directionalData.lastContactAt;
delete directionalData.lastContactById;
delete directionalData.lastContactItemMessageId;
delete directionalData.lastContactItemCalendarEventId;
if (Object.keys(directionalData).length === 0) {
return;
}
await executeWithRetry(() =>
client.mutation({
updatePerson: {
__args: { id: personId, data: directionalData },
id: true,
},
}),
);
return;
}
await executeWithRetry(() =>
client.mutation({
updatePerson: {
__args: { id: personId, data },
id: true,
},
}),
);
};

View File

@@ -0,0 +1,188 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { chunk } from 'src/utils/chunk';
import { executeWithRetry } from 'src/utils/execute-with-retry';
import { type InteractionKind } from 'src/utils/update-person-last-contact';
const PAGE_SIZE = 200;
export type RelatedInteraction = {
occurredAt: string;
itemId: string;
kind: InteractionKind;
};
const recencyGuard = (occurredAt: string) => ({
or: [
{ lastContactAt: { is: 'NULL' } },
{ lastContactAt: { lt: occurredAt } },
],
});
const buildData = ({
occurredAt,
itemId,
kind,
}: RelatedInteraction): Record<string, string | null> => ({
lastContactAt: occurredAt,
lastContactItemMessageId: kind === 'email' ? itemId : null,
lastContactItemCalendarEventId: kind === 'meeting' ? itemId : null,
});
const collectCompanyIdByPersonId = async (
client: CoreApiClient,
personIds: string[],
): Promise<Map<string, string>> => {
const companyIdByPersonId = new Map<string, string>();
for (const ids of chunk(personIds, PAGE_SIZE)) {
let after: string | undefined;
do {
const { people } = await executeWithRetry(() =>
client.query({
people: {
__args: { filter: { id: { in: ids } }, first: PAGE_SIZE, after },
edges: { node: { id: true, companyId: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
}),
);
for (const edge of people?.edges ?? []) {
const { id, companyId } = edge.node;
if (id && companyId) {
companyIdByPersonId.set(id, companyId);
}
}
after = people?.pageInfo.hasNextPage
? (people.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
}
return companyIdByPersonId;
};
const collectPointOfContactIds = async (
client: CoreApiClient,
personIds: string[],
): Promise<Set<string>> => {
const pointOfContactIds = new Set<string>();
for (const ids of chunk(personIds, PAGE_SIZE)) {
let after: string | undefined;
do {
const { opportunities } = await executeWithRetry(() =>
client.query({
opportunities: {
__args: {
filter: { pointOfContactId: { in: ids } },
first: PAGE_SIZE,
after,
},
edges: { node: { id: true, pointOfContactId: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
}),
);
for (const edge of opportunities?.edges ?? []) {
const { pointOfContactId } = edge.node;
if (pointOfContactId) {
pointOfContactIds.add(pointOfContactId);
}
}
after = opportunities?.pageInfo.hasNextPage
? (opportunities.pageInfo.endCursor ?? undefined)
: undefined;
} while (after);
}
return pointOfContactIds;
};
// Companies and opportunities surface emails and meetings from their related
// people, so their last contact mirrors the most recent contact of any person
// connected to them.
export const updateRelatedLastContactForPeople = async (
client: CoreApiClient,
contactByPersonId: Map<string, RelatedInteraction>,
): Promise<void> => {
const personIds = [...contactByPersonId.keys()];
if (personIds.length === 0) {
return;
}
const companyIdByPersonId = await collectCompanyIdByPersonId(
client,
personIds,
);
const contactByCompanyId = new Map<string, RelatedInteraction>();
for (const [personId, contact] of contactByPersonId) {
const companyId = companyIdByPersonId.get(personId);
if (!companyId) {
continue;
}
const current = contactByCompanyId.get(companyId);
if (!current || contact.occurredAt > current.occurredAt) {
contactByCompanyId.set(companyId, contact);
}
}
for (const [companyId, contact] of contactByCompanyId) {
await executeWithRetry(() =>
client.mutation({
updateCompanies: {
__args: {
data: buildData(contact),
filter: {
and: [
{ id: { eq: companyId } },
recencyGuard(contact.occurredAt),
],
},
},
id: true,
},
}),
);
}
const pointOfContactIds = await collectPointOfContactIds(client, personIds);
for (const personId of pointOfContactIds) {
const contact = contactByPersonId.get(personId);
if (!contact) {
continue;
}
await executeWithRetry(() =>
client.mutation({
updateOpportunities: {
__args: {
data: buildData(contact),
filter: {
and: [
{ pointOfContactId: { eq: personId } },
recencyGuard(contact.occurredAt),
],
},
},
id: true,
},
}),
);
}
};

View File

@@ -0,0 +1,42 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"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"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -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,
},
},
});

View File

@@ -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