diff --git a/.github/actions/spawn-twenty-app-dev-test/action.yml b/.github/actions/spawn-twenty-app-dev-test/action.yml index 41f3b6de0f..2cb6795955 100644 --- a/.github/actions/spawn-twenty-app-dev-test/action.yml +++ b/.github/actions/spawn-twenty-app-dev-test/action.yml @@ -30,6 +30,8 @@ runs: -e NODE_PORT=2021 \ -e SERVER_URL=http://localhost:2021 \ -e MARKETPLACE_CATALOG_SYNC_CRON_ENABLED=false \ + -e API_RATE_LIMITING_SHORT_LIMIT=100000 \ + -e API_RATE_LIMITING_LONG_LIMIT=100000 \ twentycrm/twenty-app-dev:${{ inputs.twenty-version }} echo "Waiting for Twenty test instance to become healthy…" diff --git a/packages/twenty-apps/public/twenty-last-contact/README.md b/packages/twenty-apps/public/twenty-last-contact/README.md index 46ea8c9ca6..54904b547a 100644 --- a/packages/twenty-apps/public/twenty-last-contact/README.md +++ b/packages/twenty-apps/public/twenty-last-contact/README.md @@ -1,25 +1,24 @@ -# Last contacted at +# Last Contact -A [Twenty](https://twenty.com) official application that adds a `lastContactAt` field to the standard Person object and keeps it in sync with email and calendar activity — answering the question every CRM should answer instantly: *when did we last talk to this person?* +**Always know where every relationship stands — live on your People list, straight from your synced email and calendar.** -## Why teams install it +## ✨ What you get -- **Zero manual logging** — every synced email and calendar meeting updates the field automatically, in real time. -- **Useful from minute one** — on install, your entire email and meeting history is backfilled. No empty columns, no waiting. -- **Spot cold relationships instantly** — sort or filter any People view by Last Contact to build follow-up lists in seconds. -- **Meeting-aware** — a meeting counts as contact the moment it starts, not whenever someone remembers to update the CRM. +- **Seven live columns** — last contact, who reached out (you or them), the owning teammate, and the exact email or meeting behind it +- **Zero upkeep** — updates in real time from every synced email and meeting, with your full history backfilled the moment you install +- **Follow-ups made obvious** — sort by recency to catch cold relationships and see who owes whom a reply -## What it does +## 📊 The columns -- Adds a **Last Contact** (`lastContactAt`, `DATE_TIME`) field on Person, visible in the All People view. -- Sets the field to the most recent interaction whenever a synced email or calendar event is linked to a person. -- Counts a meeting as contact when it starts, via a cron-triggered logic function. -- Backfills the field from existing message and calendar history right after install. +- **Last contact** — the most recent touch, either direction +- **Last outbound** / **Last inbound** — when you last reached out vs. when they last did +- **Last contact by** — the teammate connected to this person +- **Last contact item** / **Last email** / **Last meeting** — one click to the actual record -No setup, no configuration — install it, open People, and you immediately know who needs a follow-up. +## 💳 Billing -### Application variables +**Free** — no credits, no metering. -| Variable | Default | Description | -| --- | --- | --- | -| `CALENDAR_CRON_INTERVAL_MINUTES` | `5` | Interval between runs of `on-calendar-event-started`. The cron scans events that started within the last interval plus a 5-minute safety overlap. | +## 📌 Heads up + +Needs a synced inbox or calendar (Google, Outlook, or CalDAV). Direction is inferred from the email's sender and the meeting's organizer; a meeting counts as both. diff --git a/packages/twenty-apps/public/twenty-last-contact/package.json b/packages/twenty-apps/public/twenty-last-contact/package.json index 53cf13054a..3577bba5ae 100644 --- a/packages/twenty-apps/public/twenty-last-contact/package.json +++ b/packages/twenty-apps/public/twenty-last-contact/package.json @@ -1,6 +1,6 @@ { "name": "@twentyhq/last-contacted-at", - "version": "0.1.0", + "version": "1.0.0", "license": "MIT", "engines": { "node": "^24.5.0", diff --git a/packages/twenty-apps/public/twenty-last-contact/public/gallery/cover.png b/packages/twenty-apps/public/twenty-last-contact/public/gallery/cover.png index 83c6a1683e..6bc5e08b4c 100644 Binary files a/packages/twenty-apps/public/twenty-last-contact/public/gallery/cover.png and b/packages/twenty-apps/public/twenty-last-contact/public/gallery/cover.png differ diff --git a/packages/twenty-apps/public/twenty-last-contact/src/__tests__/last-contact.integration-test.ts b/packages/twenty-apps/public/twenty-last-contact/src/__tests__/last-contact.integration-test.ts index fa61a2e158..8d5fa3464b 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/__tests__/last-contact.integration-test.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/__tests__/last-contact.integration-test.ts @@ -60,11 +60,28 @@ const createCalendarEvent = async ( const createCalendarEventParticipant = async ( client: CoreApiClient, - { calendarEventId, personId }: { calendarEventId: string; personId: string }, + { + calendarEventId, + personId, + workspaceMemberId, + isOrganizer, + }: { + calendarEventId: string; + personId?: string; + workspaceMemberId?: string; + isOrganizer?: boolean; + }, ): Promise => { const result = await client.mutation({ createCalendarEventParticipant: { - __args: { data: { calendarEventId, personId } }, + __args: { + data: { + calendarEventId, + ...(personId ? { personId } : {}), + ...(workspaceMemberId ? { workspaceMemberId } : {}), + ...(isOrganizer !== undefined ? { isOrganizer } : {}), + }, + }, id: true, }, }); @@ -75,9 +92,42 @@ const createCalendarEventParticipant = async ( ); }; +let cachedWorkspaceMemberId: string | undefined; +let cachedMessageChannelId: string | undefined; + +const getWorkspaceMemberId = async ( + client: CoreApiClient, +): Promise => { + if (cachedWorkspaceMemberId) { + return cachedWorkspaceMemberId; + } + + const result = await client.query({ + workspaceMembers: { + __args: { first: 1 }, + edges: { node: { id: true } }, + }, + }); + + const workspaceMemberId = + result.workspaceMembers?.edges?.[0]?.node?.id; + + if (!workspaceMemberId) { + throw new Error('No workspace member found in the test workspace'); + } + + cachedWorkspaceMemberId = workspaceMemberId; + + return workspaceMemberId; +}; + const getAnyMessageChannelId = async ( client: CoreApiClient, ): Promise => { + if (cachedMessageChannelId) { + return cachedMessageChannelId; + } + const result = await client.query({ messageChannelMessageAssociations: { __args: { first: 1 }, @@ -95,6 +145,8 @@ const getAnyMessageChannelId = async ( ); } + cachedMessageChannelId = messageChannelId; + return messageChannelId; }; @@ -134,21 +186,117 @@ const createMessageChannelAssociation = async ( ); }; -const getPersonLastContactAt = async ( +const createMessageParticipant = async ( + client: CoreApiClient, + { + messageId, + role = 'FROM', + personId, + workspaceMemberId, + }: { + messageId: string; + role?: string; + personId?: string; + workspaceMemberId?: string; + }, +): Promise => { + const result = await client.mutation({ + createMessageParticipant: { + __args: { + data: { + messageId, + role, + ...(personId ? { personId } : {}), + ...(workspaceMemberId ? { workspaceMemberId } : {}), + }, + }, + id: true, + }, + }); + + return requireId( + result.createMessageParticipant?.id, + 'createMessageParticipant', + ); +}; + +type PersonLastContact = { + lastContactAt: string | null; + lastContactById: string | null; + lastContactItemMessageId: string | null; + lastContactItemCalendarEventId: string | null; + lastOutboundAt: string | null; + lastInboundAt: string | null; + lastEmailId: string | null; + lastMeetingId: string | null; +}; + +const getPersonLastContact = async ( client: CoreApiClient, personId: string, -): Promise => { +): Promise => { const result = await client.query({ person: { __args: { filter: { id: { eq: personId } } }, id: true, lastContactAt: true, + lastContactById: true, + lastOutboundAt: true, + lastInboundAt: true, + lastContactItemMessage: { id: true }, + lastContactItemCalendarEvent: { id: true }, + lastEmail: { id: true }, + lastMeeting: { id: true }, }, }); - return ( - (result.person as { lastContactAt?: string | null })?.lastContactAt ?? null + const person = result.person as { + lastContactAt?: string | null; + lastContactById?: string | null; + lastOutboundAt?: string | null; + lastInboundAt?: string | null; + lastContactItemMessage?: { id: string } | null; + lastContactItemCalendarEvent?: { id: string } | null; + lastEmail?: { id: string } | null; + lastMeeting?: { id: string } | null; + } | null; + + return { + lastContactAt: person?.lastContactAt ?? null, + lastContactById: person?.lastContactById ?? null, + lastContactItemMessageId: person?.lastContactItemMessage?.id ?? null, + lastContactItemCalendarEventId: + person?.lastContactItemCalendarEvent?.id ?? null, + lastOutboundAt: person?.lastOutboundAt ?? null, + lastInboundAt: person?.lastInboundAt ?? null, + lastEmailId: person?.lastEmail?.id ?? null, + lastMeetingId: person?.lastMeeting?.id ?? null, + }; +}; + +const expectColumns = ( + actual: PersonLastContact, + expected: { + lastContactAt: string | null; + lastContactById: string | null; + itemMessageId: string | null; + itemCalendarEventId: string | null; + lastOutboundAt: string | null; + lastInboundAt: string | null; + lastEmailId: string | null; + lastMeetingId: string | null; + }, +): void => { + expect(asTime(actual.lastContactAt)).toBe(asTime(expected.lastContactAt)); + expect(actual.lastContactById).toBe(expected.lastContactById); + expect(actual.lastContactItemMessageId).toBe(expected.itemMessageId); + expect(actual.lastContactItemCalendarEventId).toBe( + expected.itemCalendarEventId, ); + expect(asTime(actual.lastOutboundAt)).toBe(asTime(expected.lastOutboundAt)); + expect(asTime(actual.lastInboundAt)).toBe(asTime(expected.lastInboundAt)); + expect(actual.lastEmailId).toBe(expected.lastEmailId); + expect(actual.lastMeetingId).toBe(expected.lastMeetingId); }; describe('App installation', () => { @@ -176,12 +324,16 @@ describe('last contact handlers', () => { let client: CoreApiClient; const createdParticipantIds: string[] = []; + const createdMessageParticipantIds: string[] = []; const createdCalendarEventIds: string[] = []; const createdMessageAssociationIds: string[] = []; const createdMessageIds: string[] = []; const createdPersonIds: string[] = []; - const createLinkedMessage = async (receivedAt: string): Promise => { + const createLinkedMessage = async ( + receivedAt: string, + personId: string, + ): Promise => { const messageId = await createMessage(client, { receivedAt }); createdMessageIds.push(messageId); const messageChannelId = await getAnyMessageChannelId(client); @@ -190,10 +342,96 @@ describe('last contact handlers', () => { messageChannelId, }); createdMessageAssociationIds.push(associationId); + const participantId = await createMessageParticipant(client, { + messageId, + personId, + }); + createdMessageParticipantIds.push(participantId); return messageId; }; + const recordEmail = async ({ + personId, + workspaceMemberId, + receivedAt, + direction, + }: { + personId: string; + workspaceMemberId: string; + receivedAt: string; + direction: 'outbound' | 'inbound'; + }): Promise => { + const messageId = await createMessage(client, { receivedAt }); + createdMessageIds.push(messageId); + + const sender = + direction === 'outbound' ? { workspaceMemberId } : { personId }; + const recipient = + direction === 'outbound' ? { personId } : { workspaceMemberId }; + + createdMessageParticipantIds.push( + await createMessageParticipant(client, { + messageId, + role: 'FROM', + ...sender, + }), + ); + createdMessageParticipantIds.push( + await createMessageParticipant(client, { + messageId, + role: 'TO', + ...recipient, + }), + ); + + await emailHandler({ + recordId: 'unused-participant-id', + properties: { + updatedFields: ['personId'], + after: { id: 'unused-participant-id', personId, messageId }, + }, + }); + + return messageId; + }; + + const recordMeeting = async ({ + personId, + workspaceMemberId, + startsAt, + }: { + personId: string; + workspaceMemberId: string; + startsAt: string; + }): Promise => { + const calendarEventId = await createCalendarEvent(client, { startsAt }); + createdCalendarEventIds.push(calendarEventId); + createdParticipantIds.push( + await createCalendarEventParticipant(client, { + calendarEventId, + personId, + }), + ); + createdParticipantIds.push( + await createCalendarEventParticipant(client, { + calendarEventId, + workspaceMemberId, + isOrganizer: true, + }), + ); + + await calendarHandler({ + recordId: 'unused-participant-id', + properties: { + updatedFields: ['personId'], + after: { id: 'unused-participant-id', personId }, + }, + }); + + return calendarEventId; + }; + beforeEach(() => { client = new CoreApiClient(); }); @@ -208,6 +446,13 @@ describe('last contact handlers', () => { } createdParticipantIds.length = 0; + for (const id of createdMessageParticipantIds) { + await client + .mutation({ destroyMessageParticipant: { __args: { id }, id: true } }) + .catch(() => {}); + } + createdMessageParticipantIds.length = 0; + for (const id of createdCalendarEventIds) { await client .mutation({ destroyCalendarEvent: { __args: { id }, id: true } }) @@ -243,7 +488,9 @@ describe('last contact handlers', () => { const personId = await createPerson(client); createdPersonIds.push(personId); - expect(await getPersonLastContactAt(client, personId)).toBeNull(); + expect( + (await getPersonLastContact(client, personId)).lastContactAt, + ).toBeNull(); }); it('should set lastContactAt to the event startsAt when a person attended a past calendar event', async () => { @@ -266,9 +513,10 @@ describe('last contact handlers', () => { }, }); - expect(asTime(await getPersonLastContactAt(client, personId))).toBe( - asTime(startsAt), - ); + const lastContact = await getPersonLastContact(client, personId); + expect(asTime(lastContact.lastContactAt)).toBe(asTime(startsAt)); + expect(lastContact.lastContactItemCalendarEventId).toBe(calendarEventId); + expect(lastContact.lastContactItemMessageId).toBeNull(); }); it('should not set lastContactAt when the calendar event is in the future', async () => { @@ -291,7 +539,9 @@ describe('last contact handlers', () => { }, }); - expect(await getPersonLastContactAt(client, personId)).toBeNull(); + expect( + (await getPersonLastContact(client, personId)).lastContactAt, + ).toBeNull(); }); it('should not set lastContactAt when the past calendar event is canceled', async () => { @@ -317,14 +567,16 @@ describe('last contact handlers', () => { }, }); - expect(await getPersonLastContactAt(client, personId)).toBeNull(); + expect( + (await getPersonLastContact(client, personId)).lastContactAt, + ).toBeNull(); }); it('should set lastContactAt to the message receivedAt when a person is matched on an email', async () => { const receivedAt = new Date(Date.now() - DAY_IN_MS).toISOString(); const personId = await createPerson(client); createdPersonIds.push(personId); - const messageId = await createLinkedMessage(receivedAt); + const messageId = await createLinkedMessage(receivedAt, personId); await emailHandler({ recordId: 'unused-participant-id', @@ -334,9 +586,10 @@ describe('last contact handlers', () => { }, }); - expect(asTime(await getPersonLastContactAt(client, personId))).toBe( - asTime(receivedAt), - ); + const lastContact = await getPersonLastContact(client, personId); + expect(asTime(lastContact.lastContactAt)).toBe(asTime(receivedAt)); + expect(lastContact.lastContactItemMessageId).toBe(messageId); + expect(lastContact.lastContactItemCalendarEventId).toBeNull(); }); it('should not overwrite a newer lastContactAt with an older interaction', async () => { @@ -344,8 +597,8 @@ describe('last contact handlers', () => { const olderReceivedAt = new Date(Date.now() - 3 * DAY_IN_MS).toISOString(); const personId = await createPerson(client); createdPersonIds.push(personId); - const newerMessageId = await createLinkedMessage(newerReceivedAt); - const olderMessageId = await createLinkedMessage(olderReceivedAt); + const newerMessageId = await createLinkedMessage(newerReceivedAt, personId); + const olderMessageId = await createLinkedMessage(olderReceivedAt, personId); await emailHandler({ recordId: 'unused-participant-id', @@ -370,8 +623,175 @@ describe('last contact handlers', () => { }, }); - expect(asTime(await getPersonLastContactAt(client, personId))).toBe( - asTime(newerReceivedAt), - ); + expect( + asTime((await getPersonLastContact(client, personId)).lastContactAt), + ).toBe(asTime(newerReceivedAt)); + }); + + it('computes all columns for a single sent (outbound) email', async () => { + const workspaceMemberId = await getWorkspaceMemberId(client); + const personId = await createPerson(client); + createdPersonIds.push(personId); + const receivedAt = new Date(Date.now() - 5 * DAY_IN_MS).toISOString(); + + const messageId = await recordEmail({ + personId, + workspaceMemberId, + receivedAt, + direction: 'outbound', + }); + + expectColumns(await getPersonLastContact(client, personId), { + lastContactAt: receivedAt, + lastContactById: workspaceMemberId, + itemMessageId: messageId, + itemCalendarEventId: null, + lastOutboundAt: receivedAt, + lastInboundAt: null, + lastEmailId: messageId, + lastMeetingId: null, + }); + }); + + it('computes all columns for a single received (inbound) email', async () => { + const workspaceMemberId = await getWorkspaceMemberId(client); + const personId = await createPerson(client); + createdPersonIds.push(personId); + const receivedAt = new Date(Date.now() - 5 * DAY_IN_MS).toISOString(); + + const messageId = await recordEmail({ + personId, + workspaceMemberId, + receivedAt, + direction: 'inbound', + }); + + expectColumns(await getPersonLastContact(client, personId), { + lastContactAt: receivedAt, + lastContactById: workspaceMemberId, + itemMessageId: messageId, + itemCalendarEventId: null, + lastOutboundAt: null, + lastInboundAt: receivedAt, + lastEmailId: messageId, + lastMeetingId: null, + }); + }); + + it('computes all columns for a single meeting (counts as both directions)', async () => { + const workspaceMemberId = await getWorkspaceMemberId(client); + const personId = await createPerson(client); + createdPersonIds.push(personId); + const startsAt = new Date(Date.now() - 5 * DAY_IN_MS).toISOString(); + + const calendarEventId = await recordMeeting({ + personId, + workspaceMemberId, + startsAt, + }); + + expectColumns(await getPersonLastContact(client, personId), { + lastContactAt: startsAt, + lastContactById: workspaceMemberId, + itemMessageId: null, + itemCalendarEventId: calendarEventId, + lastOutboundAt: startsAt, + lastInboundAt: startsAt, + lastEmailId: null, + lastMeetingId: calendarEventId, + }); + }); + + it('computes columns through an inbound email, then a later meeting, then a later outbound email', async () => { + const workspaceMemberId = await getWorkspaceMemberId(client); + const personId = await createPerson(client); + createdPersonIds.push(personId); + const inboundAt = new Date(Date.now() - 5 * DAY_IN_MS).toISOString(); + const meetingAt = new Date(Date.now() - 4 * DAY_IN_MS).toISOString(); + const outboundAt = new Date(Date.now() - 3 * DAY_IN_MS).toISOString(); + + const inboundMessageId = await recordEmail({ + personId, + workspaceMemberId, + receivedAt: inboundAt, + direction: 'inbound', + }); + + expectColumns(await getPersonLastContact(client, personId), { + lastContactAt: inboundAt, + lastContactById: workspaceMemberId, + itemMessageId: inboundMessageId, + itemCalendarEventId: null, + lastOutboundAt: null, + lastInboundAt: inboundAt, + lastEmailId: inboundMessageId, + lastMeetingId: null, + }); + + const calendarEventId = await recordMeeting({ + personId, + workspaceMemberId, + startsAt: meetingAt, + }); + + expectColumns(await getPersonLastContact(client, personId), { + lastContactAt: meetingAt, + lastContactById: workspaceMemberId, + itemMessageId: null, + itemCalendarEventId: calendarEventId, + lastOutboundAt: meetingAt, + lastInboundAt: meetingAt, + lastEmailId: inboundMessageId, + lastMeetingId: calendarEventId, + }); + + const outboundMessageId = await recordEmail({ + personId, + workspaceMemberId, + receivedAt: outboundAt, + direction: 'outbound', + }); + + expectColumns(await getPersonLastContact(client, personId), { + lastContactAt: outboundAt, + lastContactById: workspaceMemberId, + itemMessageId: outboundMessageId, + itemCalendarEventId: null, + lastOutboundAt: outboundAt, + lastInboundAt: meetingAt, + lastEmailId: outboundMessageId, + lastMeetingId: calendarEventId, + }); + }); + + it('lets a later meeting supersede an earlier outbound email', async () => { + const workspaceMemberId = await getWorkspaceMemberId(client); + const personId = await createPerson(client); + createdPersonIds.push(personId); + const emailAt = new Date(Date.now() - 5 * DAY_IN_MS).toISOString(); + const meetingAt = new Date(Date.now() - 4 * DAY_IN_MS).toISOString(); + + const messageId = await recordEmail({ + personId, + workspaceMemberId, + receivedAt: emailAt, + direction: 'outbound', + }); + const calendarEventId = await recordMeeting({ + personId, + workspaceMemberId, + startsAt: meetingAt, + }); + + expectColumns(await getPersonLastContact(client, personId), { + lastContactAt: meetingAt, + lastContactById: workspaceMemberId, + itemMessageId: null, + itemCalendarEventId: calendarEventId, + lastOutboundAt: meetingAt, + lastInboundAt: meetingAt, + lastEmailId: messageId, + lastMeetingId: calendarEventId, + }); }); }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/application-config.ts b/packages/twenty-apps/public/twenty-last-contact/src/application-config.ts index b4d118cf97..b2e367a322 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/application-config.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/application-config.ts @@ -4,7 +4,6 @@ import { APP_DESCRIPTION, APP_DISPLAY_NAME, APPLICATION_UNIVERSAL_IDENTIFIER, - CALENDAR_CRON_INTERVAL_MINUTES_VARIABLE_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; export default defineApplication({ @@ -14,14 +13,4 @@ export default defineApplication({ screenshots: ['public/gallery/cover.png'], displayName: APP_DISPLAY_NAME, description: APP_DESCRIPTION, - applicationVariables: { - CALENDAR_CRON_INTERVAL_MINUTES: { - universalIdentifier: - CALENDAR_CRON_INTERVAL_MINUTES_VARIABLE_UNIVERSAL_IDENTIFIER, - description: - 'Interval in minutes between runs of the on-calendar-event-started cron.', - value: '5', - isSecret: false, - }, - }, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/constants/calendar-cron-interval-minutes.ts b/packages/twenty-apps/public/twenty-last-contact/src/constants/calendar-cron-interval-minutes.ts new file mode 100644 index 0000000000..cb2c820a5c --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/constants/calendar-cron-interval-minutes.ts @@ -0,0 +1 @@ +export const CALENDAR_CRON_INTERVAL_MINUTES = 5; diff --git a/packages/twenty-apps/public/twenty-last-contact/src/constants/calendar-cron-security-overlap-minutes.ts b/packages/twenty-apps/public/twenty-last-contact/src/constants/calendar-cron-security-overlap-minutes.ts new file mode 100644 index 0000000000..e4bd02064f --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/constants/calendar-cron-security-overlap-minutes.ts @@ -0,0 +1 @@ +export const CALENDAR_CRON_SECURITY_OVERLAP_MINUTES = 10; diff --git a/packages/twenty-apps/public/twenty-last-contact/src/constants/universal-identifiers.ts b/packages/twenty-apps/public/twenty-last-contact/src/constants/universal-identifiers.ts index 39b9114247..dd967fbd27 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/constants/universal-identifiers.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/constants/universal-identifiers.ts @@ -1,6 +1,6 @@ export const APP_DISPLAY_NAME = 'Last contact'; export const APP_DESCRIPTION = - 'Always know when you last talked to anyone. Adds a Last Contact field to People, kept up to date automatically from your synced emails and meetings.'; + 'Know where every relationship stands. Adds Last contact, Last outbound, Last inbound, Last contact by, and the last email and meeting to People, kept up to date automatically from your synced emails and meetings.'; export const APPLICATION_UNIVERSAL_IDENTIFIER = '66a504cc-0a75-410e-a43f-cdeae1db1522'; export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = '34187abe-1b98-4153-85cd-4808e0aebf30'; export const LAST_CONTACT_AT_FIELD_UNIVERSAL_IDENTIFIER = @@ -15,5 +15,38 @@ export const BACKFILL_POST_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = 'c94f671f-b3fa-47a2-8de6-dde94d13f8d1'; export const CALENDAR_EVENT_STARTED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = 'c56013d7-208b-46e2-a91f-27f481645591'; -export const CALENDAR_CRON_INTERVAL_MINUTES_VARIABLE_UNIVERSAL_IDENTIFIER = - '71cb72d1-9b5d-40fb-82d0-079495af32b1'; + +export const LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER = + 'cfdee7bd-8d41-41e6-a888-512705e75d7b'; +export const LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER = + 'bec41e57-6b6f-4bb2-90cc-4030384dd8f6'; +export const LAST_CONTACT_BY_VIEW_FIELD_UNIVERSAL_IDENTIFIER = + 'd68d71f7-10f3-42a2-ab81-a709eee83ca7'; + +export const LAST_CONTACT_ITEM_MORPH_ID = + '97eafe97-0ee1-4857-b233-fec855f6bf1a'; +export const LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER = + 'e4abd1da-32f4-4025-8908-e7834e85b8e2'; +export const LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER = + '4dea9957-ff7d-46b8-988f-cc72f187d4b5'; +export const LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER = + 'cceb21fe-1422-40d4-bc8b-44c12c79c452'; +export const LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER = + 'f49ce9ca-5aab-445d-9749-db256aaf74b7'; +export const LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER = + '88ab5daf-0650-4813-b9ca-f413d7c9f611'; +export const LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER = + '9c264385-e833-4d88-8149-e392651eb5dd'; + +export const LAST_OUTBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER = + 'f4bbac22-8a3d-44c2-bbf2-7e28237252e1'; +export const LAST_INBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER = + '11ee7749-735d-4fcf-b127-fa6856de7ee0'; +export const LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER = + 'fd705b81-c6c6-45d3-9777-818956b7627b'; +export const LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER = + '7a3dcccb-d4fd-4839-971a-775ca7bd897f'; +export const LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER = + 'c8882287-f638-4a96-a235-1819e793e373'; +export const LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER = + '257dd874-d834-403d-9cb8-f3db7e587d02'; diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-at.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-at.field.ts index 68e3a942c9..40cfeab75f 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-at.field.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-at.field.ts @@ -12,9 +12,9 @@ export default defineField({ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, name: 'lastContactAt', type: FieldType.DATE_TIME, - label: 'Last Contact', + label: 'Last contact', description: - 'When the most recent interaction (email or calendar event) with this person occurred.', + 'When the most recent contact (email or meeting) with this person occurred, in either direction.', icon: 'IconClock', isNullable: true, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-by.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-by.field.ts new file mode 100644 index 0000000000..833cc0b1d7 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-by.field.ts @@ -0,0 +1,34 @@ +import { + defineField, + FieldType, + OnDeleteAction, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER, + LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + type: FieldType.RELATION, + name: 'lastContactBy', + label: 'Last contact by', + description: + 'The team member whose synced email or meeting was the most recent interaction with this person.', + icon: 'IconUser', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'lastContactById', + }, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-calendar-event.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-calendar-event.field.ts new file mode 100644 index 0000000000..e97d6bf22f --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-calendar-event.field.ts @@ -0,0 +1,31 @@ +import { + defineField, + FieldType, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: + LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, + type: FieldType.RELATION, + name: 'lastContactForPeople', + label: 'Last contact for', + description: 'People whose most recent contact was this meeting.', + icon: 'IconUser', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-message.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-message.field.ts new file mode 100644 index 0000000000..720ff2fb86 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-message.field.ts @@ -0,0 +1,31 @@ +import { + defineField, + FieldType, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: + LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier, + type: FieldType.RELATION, + name: 'lastContactForPeople', + label: 'Last contact for', + description: 'People whose most recent contact was this email.', + icon: 'IconUser', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-workspace-member.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-workspace-member.field.ts new file mode 100644 index 0000000000..d810dcee24 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-for-people-on-workspace-member.field.ts @@ -0,0 +1,31 @@ +import { + defineField, + FieldType, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER, + LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: + LAST_CONTACT_FOR_PEOPLE_ON_WORKSPACE_MEMBER_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + type: FieldType.RELATION, + name: 'lastContactForPeople', + label: 'Last contact for', + description: 'People whose most recent contact was through this member.', + icon: 'IconUser', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-calendar-event.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-calendar-event.field.ts new file mode 100644 index 0000000000..51c4444f2d --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-calendar-event.field.ts @@ -0,0 +1,36 @@ +import { + defineField, + FieldType, + OnDeleteAction, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + LAST_CONTACT_ITEM_MORPH_ID, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: + LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + type: FieldType.MORPH_RELATION, + name: 'lastContactItemCalendarEvent', + label: 'Last contact item', + description: 'The email or meeting that was the most recent contact.', + icon: 'IconCalendarEvent', + isNullable: true, + morphId: LAST_CONTACT_ITEM_MORPH_ID, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_CONTACT_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'lastContactItemCalendarEventId', + }, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-message.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-message.field.ts new file mode 100644 index 0000000000..10e4452336 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-item-message.field.ts @@ -0,0 +1,35 @@ +import { + defineField, + FieldType, + OnDeleteAction, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + LAST_CONTACT_ITEM_MORPH_ID, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + type: FieldType.MORPH_RELATION, + name: 'lastContactItemMessage', + label: 'Last contact item', + description: 'The email or meeting that was the most recent contact.', + icon: 'IconMessage', + isNullable: true, + morphId: LAST_CONTACT_ITEM_MORPH_ID, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_CONTACT_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'lastContactItemMessageId', + }, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email-for-people-on-message.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email-for-people-on-message.field.ts new file mode 100644 index 0000000000..7f29635262 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email-for-people-on-message.field.ts @@ -0,0 +1,30 @@ +import { + defineField, + FieldType, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER, + LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier, + type: FieldType.RELATION, + name: 'lastEmailForPeople', + label: 'Last email for', + description: 'People whose most recent email is this one.', + icon: 'IconUser', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email.field.ts new file mode 100644 index 0000000000..026c807b04 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-email.field.ts @@ -0,0 +1,33 @@ +import { + defineField, + FieldType, + OnDeleteAction, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER, + LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: LAST_EMAIL_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + type: FieldType.RELATION, + name: 'lastEmail', + label: 'Last email', + description: 'The most recent email exchanged with this person.', + icon: 'IconMail', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.message.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_EMAIL_FOR_PEOPLE_ON_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'lastEmailId', + }, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-inbound-at.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-inbound-at.field.ts new file mode 100644 index 0000000000..17f0837e15 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-inbound-at.field.ts @@ -0,0 +1,20 @@ +import { + defineField, + FieldType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { LAST_INBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: LAST_INBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + name: 'lastInboundAt', + type: FieldType.DATE_TIME, + label: 'Last inbound', + description: + 'When this person last reached out to you (an inbound email, or a meeting they organized).', + icon: 'IconMessageDown', + isNullable: true, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting-for-people-on-calendar-event.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting-for-people-on-calendar-event.field.ts new file mode 100644 index 0000000000..ecf34229e1 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting-for-people-on-calendar-event.field.ts @@ -0,0 +1,30 @@ +import { + defineField, + FieldType, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER, + LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, + type: FieldType.RELATION, + name: 'lastMeetingForPeople', + label: 'Last meeting for', + description: 'People whose most recent meeting is this one.', + icon: 'IconUser', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting.field.ts new file mode 100644 index 0000000000..b397cdc8d8 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-meeting.field.ts @@ -0,0 +1,33 @@ +import { + defineField, + FieldType, + OnDeleteAction, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER, + LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: LAST_MEETING_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + type: FieldType.RELATION, + name: 'lastMeeting', + label: 'Last meeting', + description: 'The most recent meeting with this person.', + icon: 'IconCalendarEvent', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + LAST_MEETING_FOR_PEOPLE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'lastMeetingId', + }, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/fields/last-outbound-at.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-outbound-at.field.ts new file mode 100644 index 0000000000..7c4d9ad611 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-outbound-at.field.ts @@ -0,0 +1,20 @@ +import { + defineField, + FieldType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { LAST_OUTBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export default defineField({ + universalIdentifier: LAST_OUTBOUND_AT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + name: 'lastOutboundAt', + type: FieldType.DATE_TIME, + label: 'Last outbound', + description: + 'When your team last reached out to this person (an outbound email, or a meeting your team organized).', + icon: 'IconMessageUp', + isNullable: true, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-event-started.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-event-started.test.ts index f81dd604ee..320e99a023 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-event-started.test.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-event-started.test.ts @@ -38,7 +38,19 @@ const setupQueryMock = ({ return Promise.resolve({ calendarEvents: remainingEventsPages.shift() }); } - if (query.calendarEventParticipants.__args.filter.calendarEventId) { + if (query.person) { + return Promise.resolve({ person: null }); + } + + const filter = query.calendarEventParticipants.__args.filter; + + if (filter.calendarEventId && filter.workspaceMemberId) { + return Promise.resolve({ + calendarEventParticipants: { edges: [] }, + }); + } + + if (filter.calendarEventId) { return Promise.resolve({ calendarEventParticipants: remainingParticipantsPages.shift(), }); @@ -70,7 +82,7 @@ const singlePage = (nodes: Record[]): Page => ({ beforeEach(() => { queryMock.mockReset(); mutationMock.mockReset(); - mutationMock.mockResolvedValue({ updatePeople: [] }); + mutationMock.mockResolvedValue({ updatePeople: [{ id: 'updated' }] }); }); describe('on-calendar-event-started definition', () => { @@ -139,7 +151,8 @@ describe('on-calendar-event-started handler', () => { const participantsByEventCalls = queryMock.mock.calls.filter( ([query]) => - query.calendarEventParticipants?.__args.filter.calendarEventId, + query.calendarEventParticipants?.__args.filter.calendarEventId && + !query.calendarEventParticipants?.__args.filter.workspaceMemberId, ); expect(participantsByEventCalls).toHaveLength(2); expect( diff --git a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-interaction.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-interaction.test.ts index f4d31745d9..05dd1b2410 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-interaction.test.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-interaction.test.ts @@ -30,7 +30,7 @@ const buildEvent = (personId: string | null) => ({ beforeEach(() => { queryMock.mockReset(); mutationMock.mockReset(); - mutationMock.mockResolvedValue({ updatePeople: [] }); + mutationMock.mockResolvedValue({ updatePeople: [{ id: 'updated' }] }); }); describe('on-calendar-interaction definition', () => { @@ -45,25 +45,30 @@ describe('on-calendar-interaction definition', () => { describe('on-calendar-interaction handler', () => { it('should update the person from the event payload without re-querying the participant', async () => { - queryMock.mockResolvedValue({ - calendarEventParticipants: { - edges: [ - { - node: { - id: 'participant-1', - calendarEvent: { - id: 'event-1', - startsAt: PAST_EVENT_STARTS_AT, + queryMock + .mockResolvedValueOnce({ + calendarEventParticipants: { + edges: [ + { + node: { + id: 'participant-1', + calendarEvent: { + id: 'event-1', + startsAt: PAST_EVENT_STARTS_AT, + }, }, }, - }, - ], - }, - }); + ], + }, + }) + .mockResolvedValueOnce({ + calendarEventParticipants: { edges: [] }, + }) + .mockResolvedValueOnce({ person: null }); await handler(buildEvent(PERSON_ID)); - expect(queryMock).toHaveBeenCalledTimes(1); + expect(queryMock).toHaveBeenCalledTimes(3); const queryArgs = queryMock.mock.calls[0][0]; expect(queryArgs.calendarEventParticipants.__args.filter.personId).toEqual( { eq: PERSON_ID }, @@ -72,6 +77,12 @@ describe('on-calendar-interaction handler', () => { const mutationArgs = mutationMock.mock.calls[0][0]; expect(mutationArgs.updatePeople.__args.data).toEqual({ lastContactAt: PAST_EVENT_STARTS_AT, + lastContactById: null, + lastContactItemCalendarEventId: 'event-1', + lastContactItemMessageId: null, + lastOutboundAt: PAST_EVENT_STARTS_AT, + lastInboundAt: PAST_EVENT_STARTS_AT, + lastMeetingId: 'event-1', }); }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-email-interaction.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-email-interaction.test.ts index 2deb2b1b73..0028d4cd71 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-email-interaction.test.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-email-interaction.test.ts @@ -14,6 +14,7 @@ import onEmailInteraction from '../on-email-interaction'; const PERSON_ID = '11111111-1111-1111-1111-111111111111'; const MESSAGE_ID = '22222222-2222-2222-2222-222222222222'; +const MEMBER_ID = '33333333-3333-3333-3333-333333333333'; const RECEIVED_AT = '2026-06-10T09:00:00.000Z'; const handler = onEmailInteraction.config.handler as ( @@ -37,7 +38,7 @@ const buildEvent = ({ beforeEach(() => { queryMock.mockReset(); mutationMock.mockReset(); - mutationMock.mockResolvedValue({ updatePeople: [] }); + mutationMock.mockResolvedValue({ updatePeople: [{ id: 'updated' }] }); }); describe('on-email-interaction definition', () => { @@ -51,28 +52,40 @@ describe('on-email-interaction definition', () => { }); describe('on-email-interaction handler', () => { - it('should update the person with the triggering message receivedAt', async () => { - queryMock.mockResolvedValue({ - message: { id: MESSAGE_ID, receivedAt: RECEIVED_AT }, - }); + it('sets interaction, owner, item, contacted and lastEmail for an outbound email', async () => { + queryMock + .mockResolvedValueOnce({ + messageParticipants: { + edges: [ + { + node: { + role: 'TO', + workspaceMemberId: null, + message: { receivedAt: RECEIVED_AT }, + }, + }, + { + node: { + role: 'FROM', + workspaceMemberId: MEMBER_ID, + message: { receivedAt: RECEIVED_AT }, + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ person: null }); await handler(buildEvent({ personId: PERSON_ID, messageId: MESSAGE_ID })); - expect(queryMock).toHaveBeenCalledTimes(1); - expect(queryMock).toHaveBeenCalledWith({ - message: { - __args: { filter: { id: { eq: MESSAGE_ID } } }, - id: true, - receivedAt: true, - }, - }); - expect(mutationMock).toHaveBeenCalledTimes(1); - const mutationArgs = mutationMock.mock.calls[0][0]; - expect(mutationArgs.updatePeople.__args.data).toEqual({ + const data = mutationMock.mock.calls[0][0].updatePeople.__args.data; + expect(data).toEqual({ lastContactAt: RECEIVED_AT, - }); - expect(mutationArgs.updatePeople.__args.filter.and).toContainEqual({ - id: { eq: PERSON_ID }, + lastContactById: MEMBER_ID, + lastContactItemMessageId: MESSAGE_ID, + lastContactItemCalendarEventId: null, + lastOutboundAt: RECEIVED_AT, + lastEmailId: MESSAGE_ID, }); }); @@ -92,7 +105,17 @@ describe('on-email-interaction handler', () => { it('should not update the person when the message has no receivedAt', async () => { queryMock.mockResolvedValue({ - message: { id: MESSAGE_ID, receivedAt: null }, + messageParticipants: { + edges: [ + { + node: { + role: 'FROM', + workspaceMemberId: MEMBER_ID, + message: { receivedAt: null }, + }, + }, + ], + }, }); await handler(buildEvent({ personId: PERSON_ID, messageId: MESSAGE_ID })); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/backfill-last-contact-at.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/backfill-last-contact-at.ts deleted file mode 100644 index 0010395735..0000000000 --- a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/backfill-last-contact-at.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { definePostInstallLogicFunction } from 'twenty-sdk/define'; -import { CoreApiClient } from 'twenty-client-sdk/core'; - -import { BACKFILL_POST_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; - -const PAGE_SIZE = 200; -const UPDATE_BATCH_SIZE = 20; - -type LastContactAtByPersonId = Map; - -const recordContact = ( - contacts: LastContactAtByPersonId, - personId: string, - contactedAt: string, -): void => { - const current = contacts.get(personId); - if (!current || contactedAt > current) { - contacts.set(personId, contactedAt); - } -}; - -const chunk = (items: T[], size: number): T[][] => { - const chunks: T[][] = []; - for (let i = 0; i < items.length; i += size) { - chunks.push(items.slice(i, i + size)); - } - return chunks; -}; - -const collectEmailContacts = async ( - client: CoreApiClient, - contacts: LastContactAtByPersonId, -): Promise => { - let after: string | undefined; - - do { - const { messageParticipants } = await client.query({ - messageParticipants: { - __args: { - filter: { personId: { is: 'NOT_NULL' } }, - first: PAGE_SIZE, - after, - }, - edges: { - node: { - id: true, - personId: true, - message: { - id: true, - receivedAt: true, - }, - }, - }, - pageInfo: { hasNextPage: true, endCursor: true }, - }, - }); - - for (const edge of messageParticipants?.edges ?? []) { - const { personId, message } = edge.node; - if (personId && message?.receivedAt) { - recordContact(contacts, personId, message.receivedAt); - } - } - - after = messageParticipants?.pageInfo.hasNextPage - ? (messageParticipants.pageInfo.endCursor ?? undefined) - : undefined; - } while (after); -}; - -const collectCalendarContacts = async ( - client: CoreApiClient, - contacts: LastContactAtByPersonId, -): Promise => { - const now = new Date().toISOString(); - let after: string | undefined; - - do { - const { calendarEventParticipants } = await client.query({ - calendarEventParticipants: { - __args: { - filter: { personId: { is: 'NOT_NULL' } }, - first: PAGE_SIZE, - after, - }, - edges: { - node: { - id: true, - personId: true, - calendarEvent: { - id: true, - startsAt: true, - isCanceled: true, - }, - }, - }, - pageInfo: { hasNextPage: true, endCursor: true }, - }, - }); - - for (const edge of calendarEventParticipants?.edges ?? []) { - const { personId, calendarEvent } = edge.node; - if ( - personId && - calendarEvent?.startsAt && - !calendarEvent.isCanceled && - calendarEvent.startsAt <= now - ) { - recordContact(contacts, personId, calendarEvent.startsAt); - } - } - - after = calendarEventParticipants?.pageInfo.hasNextPage - ? (calendarEventParticipants.pageInfo.endCursor ?? undefined) - : undefined; - } while (after); -}; - -const findPersonsToUpdate = async ( - client: CoreApiClient, - contacts: LastContactAtByPersonId, -): Promise<{ personId: string; lastContactAt: string }[]> => { - const updates: { personId: string; lastContactAt: string }[] = []; - - for (const personIds of chunk([...contacts.keys()], PAGE_SIZE)) { - const { people } = await client.query({ - people: { - __args: { - filter: { id: { in: personIds } }, - first: personIds.length, - }, - edges: { - node: { - id: true, - lastContactAt: true, - }, - }, - }, - }); - - for (const edge of people?.edges ?? []) { - const { id, lastContactAt: currentLastContactAt } = edge.node; - const lastContactAt = contacts.get(id); - if ( - lastContactAt && - (!currentLastContactAt || currentLastContactAt < lastContactAt) - ) { - updates.push({ personId: id, lastContactAt }); - } - } - } - - return updates; -}; - -const handler = async (): Promise => { - const client = new CoreApiClient(); - const contacts: LastContactAtByPersonId = new Map(); - - await Promise.all([ - collectEmailContacts(client, contacts), - collectCalendarContacts(client, contacts), - ]); - - const updates = await findPersonsToUpdate(client, contacts); - - for (const batch of chunk(updates, UPDATE_BATCH_SIZE)) { - await Promise.all( - batch.map(({ personId, lastContactAt }) => - client.mutation({ - updatePerson: { - __args: { - id: personId, - data: { lastContactAt }, - }, - id: true, - }, - }), - ), - ); - } -}; - -export default definePostInstallLogicFunction({ - universalIdentifier: BACKFILL_POST_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, - name: 'backfill-last-contact-at', - description: - 'Fills person last-contacted fields from existing messages and calendar events after installation.', - timeoutSeconds: 300, - shouldRunOnVersionUpgrade: false, - handler, -}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/backfill-last-contact.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/backfill-last-contact.ts new file mode 100644 index 0000000000..9fd47a5530 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/backfill-last-contact.ts @@ -0,0 +1,382 @@ +import { definePostInstallLogicFunction } from 'twenty-sdk/define'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { BACKFILL_POST_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +const PAGE_SIZE = 200; +const UPDATE_BATCH_SIZE = 20; + +type EmailInteraction = { + personId: string; + messageId: string; + receivedAt: string; +}; +type MeetingInteraction = { + personId: string; + calendarEventId: string; + startsAt: string; +}; +type MessageMemberInfo = { ownerId: string; fromIsMember: boolean }; + +type PersonAgg = { + lastContactAt?: string; + lastContactById?: string | null; + item?: { kind: 'email' | 'meeting'; id: string }; + lastOutboundAt?: string; + lastInboundAt?: string; + lastEmail?: { at: string; id: string }; + lastMeeting?: { at: string; id: string }; +}; +type AggByPersonId = Map; + +type PersonUpdateData = Record; + +const chunk = (items: T[], size: number): T[][] => { + const chunks: T[][] = []; + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)); + } + return chunks; +}; + +const collectEmailInteractions = async ( + client: CoreApiClient, +): Promise => { + const interactions: EmailInteraction[] = []; + let after: string | undefined; + + do { + const { messageParticipants } = await client.query({ + messageParticipants: { + __args: { + filter: { personId: { is: 'NOT_NULL' } }, + first: PAGE_SIZE, + after, + }, + edges: { + node: { + id: true, + personId: true, + message: { id: true, receivedAt: true }, + }, + }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + + for (const edge of messageParticipants?.edges ?? []) { + const { personId, message } = edge.node; + if (personId && message?.id && message?.receivedAt) { + interactions.push({ + personId, + messageId: message.id, + receivedAt: message.receivedAt, + }); + } + } + + after = messageParticipants?.pageInfo.hasNextPage + ? (messageParticipants.pageInfo.endCursor ?? undefined) + : undefined; + } while (after); + + return interactions; +}; + +const collectMeetingInteractions = async ( + client: CoreApiClient, +): Promise => { + const now = new Date().toISOString(); + const interactions: MeetingInteraction[] = []; + let after: string | undefined; + + do { + const { calendarEventParticipants } = await client.query({ + calendarEventParticipants: { + __args: { + filter: { personId: { is: 'NOT_NULL' } }, + first: PAGE_SIZE, + after, + }, + edges: { + node: { + id: true, + personId: true, + calendarEvent: { id: true, startsAt: true, isCanceled: true }, + }, + }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + + for (const edge of calendarEventParticipants?.edges ?? []) { + const { personId, calendarEvent } = edge.node; + if ( + personId && + calendarEvent?.id && + calendarEvent?.startsAt && + !calendarEvent.isCanceled && + calendarEvent.startsAt <= now + ) { + interactions.push({ + personId, + calendarEventId: calendarEvent.id, + startsAt: calendarEvent.startsAt, + }); + } + } + + after = calendarEventParticipants?.pageInfo.hasNextPage + ? (calendarEventParticipants.pageInfo.endCursor ?? undefined) + : undefined; + } while (after); + + return interactions; +}; + +const collectMessageMemberInfo = async ( + client: CoreApiClient, + messageIds: string[], +): Promise> => { + const infoByMessageId = new Map(); + + for (const ids of chunk(messageIds, PAGE_SIZE)) { + let after: string | undefined; + + do { + const { messageParticipants } = await client.query({ + messageParticipants: { + __args: { + filter: { + messageId: { in: ids }, + workspaceMemberId: { is: 'NOT_NULL' }, + }, + first: PAGE_SIZE, + after, + }, + edges: { + node: { messageId: true, role: true, workspaceMemberId: true }, + }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + + for (const edge of messageParticipants?.edges ?? []) { + const { messageId, role, workspaceMemberId } = edge.node; + if (!messageId || !workspaceMemberId) { + continue; + } + const info = infoByMessageId.get(messageId) ?? { + ownerId: workspaceMemberId, + fromIsMember: false, + }; + if (role === 'FROM') { + info.ownerId = workspaceMemberId; + info.fromIsMember = true; + } + infoByMessageId.set(messageId, info); + } + + after = messageParticipants?.pageInfo.hasNextPage + ? (messageParticipants.pageInfo.endCursor ?? undefined) + : undefined; + } while (after); + } + + return infoByMessageId; +}; + +const collectCalendarOwners = async ( + client: CoreApiClient, + calendarEventIds: string[], +): Promise> => { + const ownerByCalendarEventId = new Map(); + + for (const ids of chunk(calendarEventIds, PAGE_SIZE)) { + let after: string | undefined; + + do { + const { calendarEventParticipants } = await client.query({ + calendarEventParticipants: { + __args: { + filter: { + calendarEventId: { in: ids }, + workspaceMemberId: { is: 'NOT_NULL' }, + }, + first: PAGE_SIZE, + after, + }, + edges: { + node: { + calendarEventId: true, + isOrganizer: true, + workspaceMemberId: true, + }, + }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + + for (const edge of calendarEventParticipants?.edges ?? []) { + const { calendarEventId, isOrganizer, workspaceMemberId } = edge.node; + if ( + calendarEventId && + workspaceMemberId && + (!ownerByCalendarEventId.has(calendarEventId) || isOrganizer === true) + ) { + ownerByCalendarEventId.set(calendarEventId, workspaceMemberId); + } + } + + after = calendarEventParticipants?.pageInfo.hasNextPage + ? (calendarEventParticipants.pageInfo.endCursor ?? undefined) + : undefined; + } while (after); + } + + return ownerByCalendarEventId; +}; + +const foldEmail = ( + agg: PersonAgg, + receivedAt: string, + messageId: string, + info: MessageMemberInfo | undefined, +): void => { + if (!agg.lastEmail || receivedAt > agg.lastEmail.at) { + agg.lastEmail = { at: receivedAt, id: messageId }; + } + if (info?.fromIsMember) { + if (!agg.lastOutboundAt || receivedAt > agg.lastOutboundAt) { + agg.lastOutboundAt = receivedAt; + } + } else if (!agg.lastInboundAt || receivedAt > agg.lastInboundAt) { + agg.lastInboundAt = receivedAt; + } + if (!agg.lastContactAt || receivedAt > agg.lastContactAt) { + agg.lastContactAt = receivedAt; + agg.lastContactById = info?.ownerId ?? null; + agg.item = { kind: 'email', id: messageId }; + } +}; + +const foldMeeting = ( + agg: PersonAgg, + startsAt: string, + calendarEventId: string, + ownerId: string | null, +): void => { + if (!agg.lastMeeting || startsAt > agg.lastMeeting.at) { + agg.lastMeeting = { at: startsAt, id: calendarEventId }; + } + if (!agg.lastOutboundAt || startsAt > agg.lastOutboundAt) { + agg.lastOutboundAt = startsAt; + } + if (!agg.lastInboundAt || startsAt > agg.lastInboundAt) { + agg.lastInboundAt = startsAt; + } + if (!agg.lastContactAt || startsAt > agg.lastContactAt) { + agg.lastContactAt = startsAt; + agg.lastContactById = ownerId; + agg.item = { kind: 'meeting', id: calendarEventId }; + } +}; + +const buildData = (agg: PersonAgg): PersonUpdateData => ({ + ...(agg.lastContactAt + ? { + lastContactAt: agg.lastContactAt, + lastContactById: agg.lastContactById ?? null, + } + : {}), + ...(agg.lastOutboundAt ? { lastOutboundAt: agg.lastOutboundAt } : {}), + ...(agg.lastInboundAt ? { lastInboundAt: agg.lastInboundAt } : {}), + ...(agg.lastEmail ? { lastEmailId: agg.lastEmail.id } : {}), + ...(agg.lastMeeting ? { lastMeetingId: agg.lastMeeting.id } : {}), + ...(agg.item?.kind === 'email' + ? { + lastContactItemMessageId: agg.item.id, + lastContactItemCalendarEventId: null, + } + : agg.item?.kind === 'meeting' + ? { + lastContactItemCalendarEventId: agg.item.id, + lastContactItemMessageId: null, + } + : {}), +}); + +const handler = async (): Promise => { + const client = new CoreApiClient(); + + const [emails, meetings] = await Promise.all([ + collectEmailInteractions(client), + collectMeetingInteractions(client), + ]); + + const messageIds = [...new Set(emails.map((email) => email.messageId))]; + const calendarEventIds = [ + ...new Set(meetings.map((meeting) => meeting.calendarEventId)), + ]; + + const [messageMemberInfo, calendarOwners] = await Promise.all([ + collectMessageMemberInfo(client, messageIds), + collectCalendarOwners(client, calendarEventIds), + ]); + + const aggByPersonId: AggByPersonId = new Map(); + const aggFor = (personId: string): PersonAgg => { + const existing = aggByPersonId.get(personId); + if (existing) { + return existing; + } + const created: PersonAgg = {}; + aggByPersonId.set(personId, created); + return created; + }; + + for (const email of emails) { + foldEmail( + aggFor(email.personId), + email.receivedAt, + email.messageId, + messageMemberInfo.get(email.messageId), + ); + } + for (const meeting of meetings) { + foldMeeting( + aggFor(meeting.personId), + meeting.startsAt, + meeting.calendarEventId, + calendarOwners.get(meeting.calendarEventId) ?? null, + ); + } + + const updates = [...aggByPersonId.entries()].map(([personId, agg]) => ({ + personId, + data: buildData(agg), + })); + + for (const batch of chunk(updates, UPDATE_BATCH_SIZE)) { + await Promise.all( + batch.map(({ personId, data }) => + client.mutation({ + updatePerson: { + __args: { id: personId, data }, + id: true, + }, + }), + ), + ); + } +}; + +export default definePostInstallLogicFunction({ + universalIdentifier: BACKFILL_POST_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'backfill-last-contact', + description: + 'Fills person last-contact fields from existing messages and calendar events after installation.', + timeoutSeconds: 300, + shouldRunOnVersionUpgrade: true, + handler, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-event-started.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-event-started.ts index fa8c107669..7448473604 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-event-started.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-event-started.ts @@ -1,15 +1,10 @@ import { defineLogicFunction } from 'twenty-sdk/define'; import { CoreApiClient } from 'twenty-client-sdk/core'; +import { CALENDAR_CRON_INTERVAL_MINUTES } from 'src/constants/calendar-cron-interval-minutes'; +import { CALENDAR_CRON_SECURITY_OVERLAP_MINUTES } from 'src/constants/calendar-cron-security-overlap-minutes'; import { CALENDAR_EVENT_STARTED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { updatePersonLastContactAtFromCalendar } from 'src/utils/update-person-last-contact-at-from-calendar'; - -const CRON_INTERVAL_MINUTES = Math.min( - Math.max(Number(process.env.CALENDAR_CRON_INTERVAL_MINUTES ?? 5), 1), - 60 * 24, -); - -const SECURITY_OVERLAP_MINUTES = 5; +import { updatePersonLastContactFromCalendar } from 'src/utils/update-person-last-contact-from-calendar'; const QUERY_MAX_RECORDS = 200; @@ -19,7 +14,9 @@ const handler = async (): Promise => { const now = new Date(); const windowStart = new Date( now.getTime() - - (CRON_INTERVAL_MINUTES + SECURITY_OVERLAP_MINUTES) * 60 * 1000, + (CALENDAR_CRON_INTERVAL_MINUTES + CALENDAR_CRON_SECURITY_OVERLAP_MINUTES) * + 60 * + 1000, ); const calendarEventIds: string[] = []; @@ -103,7 +100,7 @@ const handler = async (): Promise => { await Promise.all( [...personIds].map((personId) => - updatePersonLastContactAtFromCalendar(client, personId), + updatePersonLastContactFromCalendar(client, personId), ), ); }; @@ -116,7 +113,7 @@ export default defineLogicFunction({ 'Updates last-contacted fields for participants of calendar events whose start time just passed.', timeoutSeconds: 60, cronTriggerSettings: { - pattern: `*/${CRON_INTERVAL_MINUTES} * * * *`, + pattern: `*/${CALENDAR_CRON_INTERVAL_MINUTES} * * * *`, }, handler, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-interaction.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-interaction.ts index 6688010dc0..ae28e4d6a8 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-interaction.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-interaction.ts @@ -3,7 +3,7 @@ import { type DatabaseEventPayload } from 'twenty-sdk/logic-function'; import { CoreApiClient } from 'twenty-client-sdk/core'; import { CALENDAR_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { updatePersonLastContactAtFromCalendar } from 'src/utils/update-person-last-contact-at-from-calendar'; +import { updatePersonLastContactFromCalendar } from 'src/utils/update-person-last-contact-from-calendar'; type CalendarEventParticipantUpdate = { personId?: string | null; @@ -22,7 +22,7 @@ const handler = async ( const client = new CoreApiClient(); - await updatePersonLastContactAtFromCalendar(client, personId); + await updatePersonLastContactFromCalendar(client, personId); }; export default defineLogicFunction({ diff --git a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-email-interaction.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-email-interaction.ts index b872517357..8b4c13d072 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-email-interaction.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-email-interaction.ts @@ -3,14 +3,17 @@ import type { DatabaseEventPayload } from 'twenty-sdk/logic-function'; import { CoreApiClient } from 'twenty-client-sdk/core'; import { EMAIL_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { updatePersonLastContactAtIfNewer } from 'src/utils/update-person-last-contact-at'; +import { pickContactTeamMemberId } from 'src/utils/pick-contact-team-member'; +import { updatePersonForInteraction } from 'src/utils/update-person-last-contact'; type MessageParticipantUpdate = { personId?: string | null; messageId?: string | null; }; -const handler = async (event: DatabaseEventPayload>): Promise => { +const handler = async ( + event: DatabaseEventPayload>, +): Promise => { const personId = event.properties.after.personId; const messageId = event.properties.after.messageId; @@ -20,32 +23,63 @@ const handler = async (event: DatabaseEventPayload edge.node, + ) ?? []; + const occurredAt = participants[0]?.message?.receivedAt ?? null; - if (!lastContactAt) { + if (!occurredAt) { return; } - await updatePersonLastContactAtIfNewer(client, personId, lastContactAt); + const fromParticipant = participants.find( + (participant: { role: string | null; workspaceMemberId: string | null }) => + participant.role === 'FROM', + ); + const direction = fromParticipant?.workspaceMemberId ? 'outbound' : 'inbound'; + const workspaceMemberId = pickContactTeamMemberId(participants, { + role: 'FROM', + }); + + await updatePersonForInteraction(client, { + personId, + occurredAt, + kind: 'email', + itemId: messageId, + workspaceMemberId, + direction, + }); }; export default defineLogicFunction({ universalIdentifier: EMAIL_INTERACTION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, name: 'on-email-interaction', description: - 'Updates a person\'s last-contacted fields when a new email participant is created.', + "Updates a person's last-contacted fields when a new email participant is created.", timeoutSeconds: 60, databaseEventTriggerSettings: { eventName: 'messageParticipant.updated', - updatedFields: ['personId'] + updatedFields: ['personId'], }, handler, }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/pick-contact-team-member.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/pick-contact-team-member.test.ts new file mode 100644 index 0000000000..6a799b91c3 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/pick-contact-team-member.test.ts @@ -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(); + }); +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at-from-calendar.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at-from-calendar.test.ts index 93e01d60f7..a6dc1d05d2 100644 --- a/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at-from-calendar.test.ts +++ b/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at-from-calendar.test.ts @@ -1,15 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CoreApiClient } from 'twenty-client-sdk/core'; -import { updatePersonLastContactAtFromCalendar } from 'src/utils/update-person-last-contact-at-from-calendar'; +import { updatePersonLastContactFromCalendar } from 'src/utils/update-person-last-contact-from-calendar'; const PERSON_ID = '11111111-1111-1111-1111-111111111111'; +const MEMBER_ID = '33333333-3333-3333-3333-333333333333'; +const CALENDAR_EVENT_ID = '44444444-4444-4444-4444-444444444444'; const NOW = '2026-06-12T12:00:00.000Z'; const PAST_EVENT_STARTS_AT = '2026-06-10T09:00:00.000Z'; -const buildClient = (queryResult: unknown) => { - const queryMock = vi.fn().mockResolvedValue(queryResult); - const mutationMock = vi.fn().mockResolvedValue({ updatePeople: [] }); +const buildClient = (...queryResults: unknown[]) => { + const queryMock = vi.fn(); + for (const queryResult of queryResults) { + queryMock.mockResolvedValueOnce(queryResult); + } + const mutationMock = vi.fn().mockResolvedValue({ updatePeople: [{ id: 'updated' }] }); const client = { query: queryMock, mutation: mutationMock, @@ -28,12 +33,12 @@ afterEach(() => { }); describe('updatePersonLastContactAtFromCalendar', () => { - it('should filter on past non-canceled events in the query and only fetch the latest one', async () => { + it('should query the latest past non-canceled event for the person', async () => { const { client, queryMock } = buildClient({ calendarEventParticipants: { edges: [] }, }); - await updatePersonLastContactAtFromCalendar(client, PERSON_ID); + await updatePersonLastContactFromCalendar(client, PERSON_ID); expect(queryMock).toHaveBeenCalledWith({ calendarEventParticipants: { @@ -61,29 +66,68 @@ describe('updatePersonLastContactAtFromCalendar', () => { }); }); - it('should update the person with the latest past event startsAt', async () => { - const { client, mutationMock } = buildClient({ - calendarEventParticipants: { - edges: [ - { - node: { - id: 'participant-1', - calendarEvent: { - id: 'event-1', - startsAt: PAST_EVENT_STARTS_AT, + it('sets lastContactAt, organizer member and the calendarEvent item', async () => { + const { client, queryMock, mutationMock } = buildClient( + { + calendarEventParticipants: { + edges: [ + { + node: { + id: 'participant-1', + calendarEvent: { + id: CALENDAR_EVENT_ID, + startsAt: PAST_EVENT_STARTS_AT, + }, }, }, + ], + }, + }, + { + calendarEventParticipants: { + edges: [ + { + node: { + isOrganizer: false, + workspaceMemberId: '55555555-5555-5555-5555-555555555555', + }, + }, + { node: { isOrganizer: true, workspaceMemberId: MEMBER_ID } }, + ], + }, + }, + { person: null }, + ); + + await updatePersonLastContactFromCalendar(client, PERSON_ID); + + expect(queryMock).toHaveBeenNthCalledWith(2, { + calendarEventParticipants: { + __args: { + filter: { + calendarEventId: { eq: CALENDAR_EVENT_ID }, + workspaceMemberId: { is: 'NOT_NULL' }, }, - ], + first: 200, + }, + edges: { + node: { + isOrganizer: true, + workspaceMemberId: true, + }, + }, }, }); - await updatePersonLastContactAtFromCalendar(client, PERSON_ID); - - expect(mutationMock).toHaveBeenCalledTimes(1); - const mutationArgs = mutationMock.mock.calls[0][0]; - expect(mutationArgs.updatePeople.__args.data).toEqual({ + const data = mutationMock.mock.calls[0][0].updatePeople.__args.data; + expect(data).toEqual({ lastContactAt: PAST_EVENT_STARTS_AT, + lastContactById: MEMBER_ID, + lastContactItemCalendarEventId: CALENDAR_EVENT_ID, + lastContactItemMessageId: null, + lastOutboundAt: PAST_EVENT_STARTS_AT, + lastInboundAt: PAST_EVENT_STARTS_AT, + lastMeetingId: CALENDAR_EVENT_ID, }); }); @@ -92,7 +136,7 @@ describe('updatePersonLastContactAtFromCalendar', () => { calendarEventParticipants: { edges: [] }, }); - await updatePersonLastContactAtFromCalendar(client, PERSON_ID); + await updatePersonLastContactFromCalendar(client, PERSON_ID); expect(mutationMock).not.toHaveBeenCalled(); }); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at.test.ts deleted file mode 100644 index 7b7678e9b4..0000000000 --- a/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { CoreApiClient } from 'twenty-client-sdk/core'; - -import { updatePersonLastContactAtIfNewer } from 'src/utils/update-person-last-contact-at'; - -const PERSON_ID = '11111111-1111-1111-1111-111111111111'; -const LAST_CONTACT_AT = '2026-06-01T10:00:00.000Z'; - -describe('updatePersonLastContactAtIfNewer', () => { - it('should update lastContactAt only when the value is newer or unset', async () => { - const mutationMock = vi.fn().mockResolvedValue({ updatePeople: [] }); - const client = { mutation: mutationMock } as unknown as CoreApiClient; - - await updatePersonLastContactAtIfNewer(client, PERSON_ID, LAST_CONTACT_AT); - - expect(mutationMock).toHaveBeenCalledTimes(1); - expect(mutationMock).toHaveBeenCalledWith({ - updatePeople: { - __args: { - data: { lastContactAt: LAST_CONTACT_AT }, - filter: { - and: [ - { id: { eq: PERSON_ID } }, - { - or: [ - { lastContactAt: { is: 'NULL' } }, - { lastContactAt: { lt: LAST_CONTACT_AT } }, - ], - }, - ], - }, - }, - id: true, - }, - }); - }); -}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/utils/pick-contact-team-member.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/pick-contact-team-member.ts new file mode 100644 index 0000000000..80d332960c --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/utils/pick-contact-team-member.ts @@ -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; +}; diff --git a/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-at-from-calendar.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-at-from-calendar.ts deleted file mode 100644 index e85b18c6c3..0000000000 --- a/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-at-from-calendar.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { type CoreApiClient } from 'twenty-client-sdk/core'; - -import { updatePersonLastContactAtIfNewer } from 'src/utils/update-person-last-contact-at'; - -export const updatePersonLastContactAtFromCalendar = async ( - client: CoreApiClient, - personId: string, -): Promise => { - const now = new Date().toISOString(); - - const { calendarEventParticipants } = await client.query({ - calendarEventParticipants: { - __args: { - filter: { - personId: { eq: personId }, - calendarEvent: { - startsAt: { lte: now }, - isCanceled: { eq: false }, - }, - }, - orderBy: [{ calendarEvent: { startsAt: 'DescNullsLast' } }], - first: 1, - }, - edges: { - node: { - id: true, - calendarEvent: { - id: true, - startsAt: true, - }, - }, - }, - }, - }); - - const lastContactAt = - calendarEventParticipants?.edges[0]?.node?.calendarEvent?.startsAt ?? null; - - if (!lastContactAt) { - return; - } - - await updatePersonLastContactAtIfNewer(client, personId, lastContactAt); -}; diff --git a/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-at.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-at.ts deleted file mode 100644 index 952b60b23d..0000000000 --- a/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-at.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type CoreApiClient } from 'twenty-client-sdk/core'; - -export const updatePersonLastContactAtIfNewer = async ( - client: CoreApiClient, - personId: string, - lastContactAt: string, -): Promise => { - await client.mutation({ - updatePeople: { - __args: { - data: { lastContactAt }, - filter: { - and: [ - { id: { eq: personId } }, - { - or: [ - { lastContactAt: { is: 'NULL' } }, - { lastContactAt: { lt: lastContactAt } }, - ], - }, - ], - }, - }, - id: true, - }, - }); -}; diff --git a/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-from-calendar.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-from-calendar.ts new file mode 100644 index 0000000000..6436557737 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-from-calendar.ts @@ -0,0 +1,80 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { pickContactTeamMemberId } from 'src/utils/pick-contact-team-member'; +import { updatePersonForInteraction } from 'src/utils/update-person-last-contact'; + +export const updatePersonLastContactFromCalendar = async ( + client: CoreApiClient, + personId: string, +): Promise => { + const now = new Date().toISOString(); + + const { calendarEventParticipants } = await client.query({ + calendarEventParticipants: { + __args: { + filter: { + personId: { eq: personId }, + calendarEvent: { + startsAt: { lte: now }, + isCanceled: { eq: false }, + }, + }, + orderBy: [{ calendarEvent: { startsAt: 'DescNullsLast' } }], + first: 1, + }, + edges: { + node: { + id: true, + calendarEvent: { + id: true, + startsAt: true, + }, + }, + }, + }, + }); + + const calendarEvent = + calendarEventParticipants?.edges[0]?.node?.calendarEvent ?? null; + const occurredAt = calendarEvent?.startsAt ?? null; + + if (!calendarEvent?.id || !occurredAt) { + return; + } + + const { calendarEventParticipants: memberParticipants } = await client.query({ + calendarEventParticipants: { + __args: { + filter: { + calendarEventId: { eq: calendarEvent.id }, + workspaceMemberId: { is: 'NOT_NULL' }, + }, + first: 200, + }, + edges: { + node: { + isOrganizer: true, + workspaceMemberId: true, + }, + }, + }, + }); + + const participants = + memberParticipants?.edges?.map( + (edge: { + node: { isOrganizer: boolean | null; workspaceMemberId: string | null }; + }) => edge.node, + ) ?? []; + const workspaceMemberId = pickContactTeamMemberId(participants, { + isOrganizer: true, + }); + + await updatePersonForInteraction(client, { + personId, + occurredAt, + kind: 'meeting', + itemId: calendarEvent.id, + workspaceMemberId, + }); +}; diff --git a/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact.ts new file mode 100644 index 0000000000..75582581a1 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact.ts @@ -0,0 +1,130 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export type InteractionKind = 'email' | 'meeting'; +export type InteractionDirection = 'outbound' | 'inbound'; + +export type Interaction = { + personId: string; + occurredAt: string; + itemId: string; + workspaceMemberId: string | null; +} & ({ kind: 'email'; direction: InteractionDirection } | { kind: 'meeting' }); + +const isNewer = ( + candidate: string, + current: string | null | undefined, +): boolean => !current || current < candidate; + +export const updatePersonForInteraction = async ( + client: CoreApiClient, + interaction: Interaction, +): Promise => { + const { personId, occurredAt, kind, itemId, workspaceMemberId } = interaction; + + const { person } = await client.query({ + person: { + __args: { filter: { id: { eq: personId } } }, + id: true, + lastContactAt: true, + lastOutboundAt: true, + lastInboundAt: true, + lastEmail: { receivedAt: true }, + lastMeeting: { startsAt: true }, + }, + }); + + const current = (person ?? {}) as { + lastContactAt?: string | null; + lastOutboundAt?: string | null; + lastInboundAt?: string | null; + lastEmail?: { receivedAt: string | null } | null; + lastMeeting?: { startsAt: string | null } | null; + }; + + const data: Record = {}; + + if (isNewer(occurredAt, current.lastContactAt)) { + data.lastContactAt = occurredAt; + data.lastContactById = workspaceMemberId ?? null; + if (kind === 'email') { + data.lastContactItemMessageId = itemId; + data.lastContactItemCalendarEventId = null; + } else { + data.lastContactItemCalendarEventId = itemId; + data.lastContactItemMessageId = null; + } + } + + const touchesOutbound = + interaction.kind === 'meeting' || interaction.direction === 'outbound'; + const touchesInbound = + interaction.kind === 'meeting' || interaction.direction === 'inbound'; + + if (touchesOutbound && isNewer(occurredAt, current.lastOutboundAt)) { + data.lastOutboundAt = occurredAt; + } + if (touchesInbound && isNewer(occurredAt, current.lastInboundAt)) { + data.lastInboundAt = occurredAt; + } + if (kind === 'email' && isNewer(occurredAt, current.lastEmail?.receivedAt)) { + data.lastEmailId = itemId; + } + if (kind === 'meeting' && isNewer(occurredAt, current.lastMeeting?.startsAt)) { + data.lastMeetingId = itemId; + } + + if (Object.keys(data).length === 0) { + return; + } + + if ('lastContactAt' in data) { + const { updatePeople } = await client.mutation({ + updatePeople: { + __args: { + data, + filter: { + and: [ + { id: { eq: personId } }, + { + or: [ + { lastContactAt: { is: 'NULL' } }, + { lastContactAt: { lt: occurredAt } }, + ], + }, + ], + }, + }, + id: true, + }, + }); + + if (Array.isArray(updatePeople) && updatePeople.length > 0) { + return; + } + + const directionalData: Record = { ...data }; + delete directionalData.lastContactAt; + delete directionalData.lastContactById; + delete directionalData.lastContactItemMessageId; + delete directionalData.lastContactItemCalendarEventId; + + if (Object.keys(directionalData).length === 0) { + return; + } + + await client.mutation({ + updatePerson: { + __args: { id: personId, data: directionalData }, + id: true, + }, + }); + return; + } + + await client.mutation({ + updatePerson: { + __args: { id: personId, data }, + id: true, + }, + }); +}; diff --git a/packages/twenty-apps/public/twenty-last-contact/src/view-fields/last-contact-by.view-field.ts b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/last-contact-by.view-field.ts new file mode 100644 index 0000000000..61efb28327 --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/last-contact-by.view-field.ts @@ -0,0 +1,20 @@ +import { + defineViewField, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER, + LAST_CONTACT_BY_VIEW_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineViewField({ + universalIdentifier: LAST_CONTACT_BY_VIEW_FIELD_UNIVERSAL_IDENTIFIER, + viewUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.views.allPeople + .universalIdentifier, + fieldMetadataUniversalIdentifier: LAST_CONTACT_BY_FIELD_UNIVERSAL_IDENTIFIER, + position: 7, + isVisible: true, + size: 150, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/view-fields/last-contact-item-calendar-event.view-field.ts b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/last-contact-item-calendar-event.view-field.ts new file mode 100644 index 0000000000..f33c52fccc --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/last-contact-item-calendar-event.view-field.ts @@ -0,0 +1,22 @@ +import { + defineViewField, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineViewField({ + universalIdentifier: + LAST_CONTACT_ITEM_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER, + viewUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.views.allPeople + .universalIdentifier, + fieldMetadataUniversalIdentifier: + LAST_CONTACT_ITEM_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + position: 10, + isVisible: true, + size: 180, +}); diff --git a/packages/twenty-apps/public/twenty-last-contact/src/view-fields/last-contact-item-message.view-field.ts b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/last-contact-item-message.view-field.ts new file mode 100644 index 0000000000..41866cc21b --- /dev/null +++ b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/last-contact-item-message.view-field.ts @@ -0,0 +1,21 @@ +import { + defineViewField, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineViewField({ + universalIdentifier: LAST_CONTACT_ITEM_MESSAGE_VIEW_FIELD_UNIVERSAL_IDENTIFIER, + viewUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.views.allPeople + .universalIdentifier, + fieldMetadataUniversalIdentifier: + LAST_CONTACT_ITEM_MESSAGE_FIELD_UNIVERSAL_IDENTIFIER, + position: 9, + isVisible: true, + size: 180, +});