diff --git a/packages/twenty-apps/internal/twenty-for-twenty/package.json b/packages/twenty-apps/internal/twenty-for-twenty/package.json index 3fa7d287f5..9983260faf 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/package.json +++ b/packages/twenty-apps/internal/twenty-for-twenty/package.json @@ -20,15 +20,15 @@ }, "dependencies": { "resend": "^6.12.0", - "twenty-client-sdk": "1.22.0", - "twenty-sdk": "1.22.0" + "twenty-client-sdk": "npm:twenty-client-sdk@1.23.0-canary.1", + "twenty-sdk": "npm:twenty-sdk@2.0.0" }, "devDependencies": { "@types/node": "^24.7.2", - "@types/react": "^19.0.0", + "@types/react": "^18.2.0", "oxlint": "^0.16.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", "typescript": "^5.9.3", "vite-tsconfig-paths": "^4.2.1", "vitest": "^3.1.1" diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/__tests__/schema.integration-test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/__tests__/schema.integration-test.ts index 31007e28e9..f4cddd084c 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/__tests__/schema.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/__tests__/schema.integration-test.ts @@ -1,6 +1,7 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; import { MetadataApiClient } from 'twenty-client-sdk/metadata'; -import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { APPLICATION_UNIVERSAL_IDENTIFIER } from '@constants/universal-identifiers'; +import { isDefined } from '@utils/is-defined'; import { describe, expect, it } from 'vitest'; describe('App installation', () => { @@ -15,12 +16,12 @@ describe('App installation', () => { }, }); - const app = result.findManyApplications.find( - (a: { universalIdentifier: string }) => - a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER, + const matchingApplication = result.findManyApplications.find( + (application: { universalIdentifier: string }) => + application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER, ); - expect(app).toBeDefined(); + expect(matchingApplication).toBeDefined(); }); }); @@ -34,13 +35,18 @@ describe('CoreApiClient', () => { id: true, }, }); - expect(created.createNote.id).toBeDefined(); + const createdNote = created.createNote; - await client.mutation({ - destroyNote: { - __args: { id: created.createNote.id }, - id: true, - }, - }); + expect(createdNote?.id).toBeDefined(); + + if (isDefined(createdNote)) { + await client.mutation({ + destroyNote: { + __args: { id: createdNote.id }, + id: true, + }, + }); + } }); + }); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/application-config.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/application-config.ts index 31d2f890d9..9e66c3da5e 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/application-config.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/application-config.ts @@ -5,11 +5,12 @@ import { APP_DISPLAY_NAME, APPLICATION_UNIVERSAL_IDENTIFIER, DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, -} from 'src/constants/universal-identifiers'; +} from '@constants/universal-identifiers'; import { + INITIAL_SYNC_MODE_APP_VARIABLE_UNIVERSAL_IDENTIFIER, RESEND_API_KEY_UNIVERSAL_IDENTIFIER, RESEND_WEBHOOK_SECRET_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; export default defineApplication({ universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, @@ -27,6 +28,13 @@ export default defineApplication({ description: 'Signing secret for verifying Resend webhook payloads', isSecret: true, }, + INITIAL_SYNC_MODE: { + universalIdentifier: INITIAL_SYNC_MODE_APP_VARIABLE_UNIVERSAL_IDENTIFIER, + description: + "When 'true', the initial-sync cron runs and the intermediate-sync cron is paused. Flipped to 'false' once every list has completed.", + isSecret: false, + value: 'false', + }, }, defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, }); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/default-role.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/default-role.ts index e0338e0948..a81de5c541 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/default-role.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/default-role.ts @@ -3,7 +3,7 @@ import { defineRole } from 'twenty-sdk/define'; import { APP_DISPLAY_NAME, DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, -} from 'src/constants/universal-identifiers'; +} from '@constants/universal-identifiers'; export default defineRole({ universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/constants/__tests__/sync-config.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/constants/__tests__/sync-config.test.ts new file mode 100644 index 0000000000..942a1c95b8 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/constants/__tests__/sync-config.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { + RESEND_SYNC_CRON_PATTERNS, + RESEND_SYNC_SLOT_DEADLINE_SLACK_MS, + RESEND_SYNC_SLOT_TIMEOUT_SECONDS, +} from '@modules/resend/constants/sync-config'; + +const minutesInOneHour = (pattern: string): number[] => { + const [minuteField, hourField, ...rest] = pattern.split(' '); + + expect(hourField).toBe('*'); + expect(rest).toEqual(['*', '*', '*']); + + const stepMatch = minuteField.match(/^(\d+)-59\/(\d+)$/); + + if (stepMatch === null) { + throw new Error( + `Unsupported cron minute field for this test helper: ${minuteField}`, + ); + } + + const start = Number(stepMatch[1]); + const step = Number(stepMatch[2]); + const minutes: number[] = []; + + for (let m = start; m < 60; m += step) { + minutes.push(m); + } + + return minutes; +}; + +describe('RESEND_SYNC_CRON_PATTERNS', () => { + it('runs each cron exactly once per 5-minute window with a unique minute offset', () => { + const offsets = { + EMAILS: minutesInOneHour(RESEND_SYNC_CRON_PATTERNS.EMAILS).map( + (m) => m % 5, + ), + CONTACTS: minutesInOneHour(RESEND_SYNC_CRON_PATTERNS.CONTACTS).map( + (m) => m % 5, + ), + BROADCASTS: minutesInOneHour(RESEND_SYNC_CRON_PATTERNS.BROADCASTS).map( + (m) => m % 5, + ), + TEMPLATES: minutesInOneHour(RESEND_SYNC_CRON_PATTERNS.TEMPLATES).map( + (m) => m % 5, + ), + }; + + expect(new Set(offsets.EMAILS)).toEqual(new Set([0])); + expect(new Set(offsets.CONTACTS)).toEqual(new Set([1])); + expect(new Set(offsets.BROADCASTS)).toEqual(new Set([2])); + expect(new Set(offsets.TEMPLATES)).toEqual(new Set([3])); + + const firstOffsets = [ + offsets.EMAILS[0], + offsets.CONTACTS[0], + offsets.BROADCASTS[0], + offsets.TEMPLATES[0], + ]; + + expect(new Set(firstOffsets).size).toBe(4); + }); + + it('fires every five minutes for each cron', () => { + for (const pattern of Object.values(RESEND_SYNC_CRON_PATTERNS)) { + const minutes = minutesInOneHour(pattern); + + expect(minutes).toHaveLength(12); + for (let i = 1; i < minutes.length; i++) { + expect(minutes[i] - minutes[i - 1]).toBe(5); + } + } + }); +}); + +describe('RESEND_SYNC_SLOT_TIMEOUT_SECONDS', () => { + it('keeps every 1-minute slot strictly under 60 seconds and the trailing 2-minute slot under 120 seconds', () => { + expect(RESEND_SYNC_SLOT_TIMEOUT_SECONDS.EMAILS).toBeLessThan(60); + expect(RESEND_SYNC_SLOT_TIMEOUT_SECONDS.CONTACTS).toBeLessThan(60); + expect(RESEND_SYNC_SLOT_TIMEOUT_SECONDS.BROADCASTS).toBeLessThan(60); + expect(RESEND_SYNC_SLOT_TIMEOUT_SECONDS.TEMPLATES).toBeLessThan(120); + }); + + it('leaves enough slack for the deadline check to fire before the hard timeout', () => { + expect(RESEND_SYNC_SLOT_DEADLINE_SLACK_MS).toBeGreaterThan(0); + expect(RESEND_SYNC_SLOT_DEADLINE_SLACK_MS).toBeLessThan( + RESEND_SYNC_SLOT_TIMEOUT_SECONDS.EMAILS * 1_000, + ); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/constants/sync-config.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/constants/sync-config.ts new file mode 100644 index 0000000000..4ad48f8e71 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/constants/sync-config.ts @@ -0,0 +1,31 @@ +export const RESEND_PAGE_SIZE = 100; + +export const TWENTY_PAGE_SIZE = 100; + +export const RATE_LIMIT_MAX_RETRIES = 5; + +export const RATE_LIMIT_BASE_DELAY_MS = 1000; + +export const RATE_LIMIT_MIN_INTERVAL_MS = 220; + +export const SYNC_LOOKUP_PROGRESS = 0.1; + +export const INITIAL_SYNC_MODE_ENV_VAR_NAME = 'INITIAL_SYNC_MODE'; + +export const INTERMEDIATE_SYNC_EMAILS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +export const RESEND_SYNC_CRON_PATTERNS = { + EMAILS: '0-59/5 * * * *', + CONTACTS: '1-59/5 * * * *', + BROADCASTS: '2-59/5 * * * *', + TEMPLATES: '3-59/5 * * * *', +} as const; + +export const RESEND_SYNC_SLOT_TIMEOUT_SECONDS = { + EMAILS: 55, + CONTACTS: 55, + BROADCASTS: 55, + TEMPLATES: 115, +} as const; + +export const RESEND_SYNC_SLOT_DEADLINE_SLACK_MS = 5_000; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/constants/universal-identifiers.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/constants/universal-identifiers.ts index 357e226ad4..b5fbc58e55 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/constants/universal-identifiers.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/constants/universal-identifiers.ts @@ -3,21 +3,32 @@ export const RESEND_API_KEY_UNIVERSAL_IDENTIFIER = 'e5828892-33d5-4532-b796-551df48a07c0'; export const RESEND_WEBHOOK_SECRET_UNIVERSAL_IDENTIFIER = 'b291b241-bd84-4661-8e79-3dc7a63371dd'; +export const INITIAL_SYNC_MODE_APP_VARIABLE_UNIVERSAL_IDENTIFIER = + 'b2e884a5-ccd6-401b-bcd4-3623babaf409'; // Logic functions -export const SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = - '7a3c841f-509e-46f0-b2f1-fb942b716ee3'; +export const RESEND_SYNC_BROADCASTS_AND_DEPENDENCIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '7fc0e28f-d67f-49ad-a9a5-aa62e9fa938a'; +export const RESEND_SYNC_TEMPLATES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '5a2bf3d1-5dec-4489-8bd8-2e22822ee37b'; +export const RESEND_SYNC_CONTACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + 'b48d0d04-663f-4358-9972-3c69a5553dc8'; +export const RESEND_SYNC_EMAILS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '7fd06109-a23a-4bc1-b734-83dcd0862abe'; +export const RESEND_INITIAL_SYNC_MODE_MONITOR_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + 'd8b6c007-f2a7-49e5-920f-465af4009072'; export const RESEND_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = '049b7227-3e8b-444b-84aa-939a7e4ca440'; + export const ON_RESEND_CONTACT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = '656b85b4-71d0-477b-9741-4967d8d88ac9'; export const ON_RESEND_CONTACT_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = '7b770cc2-6d31-4f1b-a7db-48d44cf6109b'; -export const ON_RESEND_CONTACT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = +export const ON_RESEND_CONTACT_DESTROYED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = '7a2341e7-d96a-4ba1-b41d-699c73d61081'; export const ON_RESEND_SEGMENT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = 'ac5b424a-f51d-46c8-a95a-42589fb81676'; -export const ON_RESEND_SEGMENT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = +export const ON_RESEND_SEGMENT_DESTROYED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = 'd5e5b6e1-e0d9-45f0-b3d9-6b96417e4ed0'; // Commands @@ -27,10 +38,16 @@ export const SYNC_RESEND_DATA_COMMAND_UNIVERSAL_IDENTIFIER = // Front components export const SYNC_RESEND_DATA_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = '96e073b8-e331-40d1-9ec0-137bc921f486'; -export const EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = - '7df0713e-3c16-4cbc-a25f-08cead363941'; export const TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = '58d79064-1e2a-4500-b8e7-c023cd9835fe'; +export const BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = + '79ad4e25-4f31-4e71-9716-4a84f4777e8d'; +export const EMAIL_BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = + '842891d2-5c7c-4a7b-b9d0-e9fbdca06fd3'; +export const RESEND_SYNC_STATUS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = + '2b9c7e1d-6a4f-4d3b-9c8e-7f5a3d6b2c4e'; +export const PERSON_RESEND_EMAIL_STATS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = + 'a5f3c1e8-2d9b-4f7a-8e6c-1b3d5f7a9c2e'; // Objects export const RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER = @@ -43,6 +60,20 @@ export const RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER = '85ddb31f-0d1c-4619-bbaf-3d208c1b9fea'; export const RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER = 'bebc114f-a8f5-455d-8c6f-e33f20f66967'; +export const RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER = + 'e3d6d1a7-09dd-4524-9662-d46a569a1864'; +export const RESEND_SYNC_CURSOR_OBJECT_UNIVERSAL_IDENTIFIER = + '3c0f80dc-4b88-4ef6-b08d-4ddd33ba3875'; + +// Object fields - Resend sync cursor +export const SYNC_CURSOR_STEP_FIELD_UNIVERSAL_IDENTIFIER = + 'cfdfc188-0db3-48f9-a750-c095263e9fbc'; +export const SYNC_CURSOR_CURSOR_FIELD_UNIVERSAL_IDENTIFIER = + '18eae3e6-cf5b-4094-9c7f-d2db236e2db1'; +export const SYNC_CURSOR_LAST_RUN_AT_FIELD_UNIVERSAL_IDENTIFIER = + 'a8f1c0e3-3bb6-4f5a-9e2c-7d12e96f4a31'; +export const SYNC_CURSOR_LAST_RUN_STATUS_FIELD_UNIVERSAL_IDENTIFIER = + '6d4f8b7a-1c52-4d8a-bf7e-12c3a59f7d6c'; // Object fields - Resend email export const SUBJECT_FIELD_UNIVERSAL_IDENTIFIER = @@ -51,10 +82,6 @@ export const FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER = 'c663f57e-0fe3-4066-91df-9eb001bab03a'; export const TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER = '613e0400-709d-496e-9be4-b6eab8282d2e'; -export const HTML_BODY_FIELD_UNIVERSAL_IDENTIFIER = - 'd14c29e4-c971-47d3-a1f1-8f459b8d8719'; -export const TEXT_BODY_FIELD_UNIVERSAL_IDENTIFIER = - '9d6edd43-903b-4e57-8bd0-8bbd9e914c30'; export const CC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER = 'eff53046-039e-444e-9142-33d7cc354ad6'; export const BCC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER = @@ -69,8 +96,6 @@ export const EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER = '732e6009-67f7-4880-8161-a4310f4df690'; export const SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER = '3d1b8deb-f102-4c1b-929e-ed7b2647e64b'; -export const TAGS_FIELD_UNIVERSAL_IDENTIFIER = - 'd6406a2c-a8b6-416e-b351-e1e5ddb3d5fe'; export const EMAIL_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER = '1b841d96-4318-48f5-aa0d-184d19e9af55'; @@ -145,6 +170,26 @@ export const BROADCAST_SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER = 'abbb4379-d0a5-432a-8c34-0e940242d687'; export const BROADCAST_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER = 'e1e281aa-7ba7-47a0-951d-0f6150a63099'; +export const BROADCAST_HTML_FIELD_UNIVERSAL_IDENTIFIER = + 'c5bb1861-5d20-4816-89ab-d3d7a35ce1f7'; +export const BROADCAST_TEXT_FIELD_UNIVERSAL_IDENTIFIER = + '60de9fcc-7ebd-4c81-ab14-4dd623c5e01e'; + +// Object fields - Resend topic +export const TOPIC_NAME_FIELD_UNIVERSAL_IDENTIFIER = + '198b6f56-0657-45f6-9c34-c23433f54fc3'; +export const TOPIC_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER = + 'bd366dbc-8cff-4d92-9236-373daba472d1'; +export const TOPIC_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER = + '58e26e08-cc90-447d-9cf4-e22afaf64679'; +export const TOPIC_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER = + 'fe7ced6a-1978-4311-bb5e-b2d55c300d82'; +export const TOPIC_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER = + 'af8a3e28-c9dd-4c61-8f93-b425cf6de452'; +export const TOPIC_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER = + '4cffe6e2-8cf2-4432-bafe-603847e7e283'; +export const TOPIC_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER = + '8cbbe9d6-151b-4594-8441-dbce3cb3efde'; // Relation fields export const SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER = @@ -171,6 +216,10 @@ export const PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER = 'cb08e862-8114-480e-986b-8f50fe49de41'; export const RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER = 'd8b69019-ca10-4599-b099-ecfc891d6438'; +export const TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER = + '05c7f816-4c1d-4d98-80e5-e24c6679b363'; +export const RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER = + '90c64f8d-1352-4f06-bd24-c13d47f0584d'; // Views export const RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER = @@ -183,6 +232,8 @@ export const RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER = '3d710924-5f47-4ac7-ba5e-28d3be9ee004'; export const RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER = '43571c5b-f71d-47bf-95b3-8741b2201315'; +export const RESEND_TOPIC_VIEW_UNIVERSAL_IDENTIFIER = + '06755bbf-a1b0-48ad-84d1-37e33cdca872'; // View fields - Resend broadcast view export const RESEND_BROADCAST_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER = @@ -201,6 +252,22 @@ export const RESEND_BROADCAST_VIEW_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER = '4d7eb630-9f44-4090-a887-7fb08e0c49ed'; export const RESEND_BROADCAST_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER = 'e1726752-fed7-4e9b-b395-e47b60f3d56d'; +export const RESEND_BROADCAST_VIEW_TOPIC_FIELD_UNIVERSAL_IDENTIFIER = + '13d2f272-4670-4021-8443-eba57f8d3423'; + +// View fields - Resend topic view +export const RESEND_TOPIC_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER = + 'b78f30af-4973-4905-ae6c-d44e0febe1b9'; +export const RESEND_TOPIC_VIEW_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER = + '4c71eabd-f5c4-4644-a67d-0ece699dd7d4'; +export const RESEND_TOPIC_VIEW_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER = + 'db96323c-9d07-47d9-9773-14068c513c75'; +export const RESEND_TOPIC_VIEW_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER = + '0ffe1090-a4e1-4c4f-ad9f-03496d29dd55'; +export const RESEND_TOPIC_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER = + 'c4c206e1-d9ca-4db1-abe0-7ccd9914df7b'; +export const RESEND_TOPIC_VIEW_BROADCASTS_FIELD_UNIVERSAL_IDENTIFIER = + '53d704d6-196c-44a3-9ddc-d91bf6194eb0'; // View fields - Resend template view export const RESEND_TEMPLATE_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER = @@ -245,6 +312,8 @@ export const RESEND_CONTACT_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER = // View fields - Resend email view export const RESEND_EMAIL_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER = '3a3ee801-6dfa-43b4-ba22-5c079a2d238d'; +export const RESEND_EMAIL_VIEW_TO_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER = + '4ddb4d8e-f597-418a-ac92-a8dbd93dbee6'; export const RESEND_EMAIL_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER = '486e23dd-3d8b-42d8-86a8-146d5fd7aaf0'; export const RESEND_EMAIL_VIEW_LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER = @@ -257,6 +326,8 @@ export const RESEND_EMAIL_VIEW_CONTACT_FIELD_UNIVERSAL_IDENTIFIER = 'abee6dcd-4754-45b2-ae77-d42bec85afad'; export const RESEND_EMAIL_VIEW_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER = '8d7df695-5ffb-46b7-9a57-ac979988351f'; +export const RESEND_EMAIL_VIEW_TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER = + 'e52cc330-bd21-460f-a539-7a1b8a119dc8'; // Navigation menu items export const RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER = @@ -271,6 +342,8 @@ export const RESEND_BROADCAST_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER = 'cb11a15d-2116-4dd2-9f7d-88ffd2271620'; export const RESEND_CONTACT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER = '7cf30c1e-3f3c-4250-9141-a6584dc6697b'; +export const RESEND_TOPIC_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER = + '398560f0-3cd2-4749-99db-8096ab43e2f5'; // Page layouts - Resend template record page export const RESEND_TEMPLATE_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = @@ -300,6 +373,34 @@ export const RESEND_TEMPLATE_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER = export const RESEND_TEMPLATE_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER = '3059a217-5322-4744-9ed0-4757591bba1c'; +// Page layouts - Resend broadcast record page +export const RESEND_BROADCAST_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = + 'bb61745d-f760-4d1b-aab6-be286a4e84c9'; +export const RESEND_BROADCAST_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER = + 'b19ff920-72f6-4b16-9049-da27f35bc6c8'; +export const RESEND_BROADCAST_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER = + '804f0a5d-32e2-414a-bb2a-b39cc98d1813'; +export const RESEND_BROADCAST_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER = + 'bb2d137f-a07d-4e8e-9f99-5d2d401b5de5'; +export const RESEND_BROADCAST_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER = + 'cd87eda6-f1d2-43ce-a8c0-b1d055d58134'; +export const RESEND_BROADCAST_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER = + '0092e23d-72b4-4e24-90c2-c2bee1c1ceb7'; +export const RESEND_BROADCAST_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER = + '9b70da94-185a-406a-8552-1013a2df58cb'; +export const RESEND_BROADCAST_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER = + 'e7d41836-d4bf-47bb-9016-0e22571eb59f'; +export const RESEND_BROADCAST_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER = + '59eb58c9-324a-4244-bbf0-d841b4dcaec2'; +export const RESEND_BROADCAST_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER = + '44347daf-da78-4868-ab47-70643994cb8b'; +export const RESEND_BROADCAST_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER = + '6dbc85dc-3f3e-4f00-94a7-0cfac5d63fca'; +export const RESEND_BROADCAST_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER = + '7dfffff1-9dbf-4abd-af23-49ea60c00bf9'; +export const RESEND_BROADCAST_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER = + '9ea88269-d202-469e-aec5-07409ef22987'; + // Page layouts - Resend email record page export const RESEND_EMAIL_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = 'e481afa9-f100-4d88-959d-d4b3518583a2'; @@ -308,9 +409,9 @@ export const RESEND_EMAIL_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER = export const RESEND_EMAIL_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER = '4eebad57-eb42-4b69-85a6-2e7c1d365b94'; export const RESEND_EMAIL_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER = - '50299e13-3652-4059-ae1e-db512869d20b'; + '074a8e3e-33b7-47de-b8da-122dccac06ed'; export const RESEND_EMAIL_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER = - '10bedce3-3e4f-4639-8500-e2035241f364'; + '4842262b-eb12-4a51-b40d-faee1c563203'; export const RESEND_EMAIL_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER = 'e6c548d4-c371-4b4c-b59c-5b5f4fe50b11'; export const RESEND_EMAIL_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER = @@ -327,3 +428,15 @@ export const RESEND_EMAIL_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER = 'c2f8a3d1-7e49-4b56-9c0a-8d1e5f3b7a92'; export const RESEND_EMAIL_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER = 'b9d4e6f2-1a38-4c75-8b0d-3f7a9c2e5d14'; + +// Page layouts - Resend sync status standalone page +export const RESEND_SYNC_STATUS_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = + '556a2d62-b21f-4958-8176-27c3ef50e00d'; +export const RESEND_SYNC_STATUS_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER = + '026d6c62-4afe-4ce0-afcb-2b82c2f71b0b'; +export const RESEND_SYNC_STATUS_PAGE_WIDGET_UNIVERSAL_IDENTIFIER = + 'a93cf282-e9b8-42e7-a4f5-1a5181b83786'; + +// Navigation menu items (additional) +export const RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER = + '8cd224de-885b-446b-9b81-d0e97e4e531c'; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/constants/email-status-groups.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/constants/email-status-groups.ts new file mode 100644 index 0000000000..9c9035459a --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/constants/email-status-groups.ts @@ -0,0 +1,108 @@ +export type ThemeColor = + | 'red' + | 'ruby' + | 'crimson' + | 'tomato' + | 'orange' + | 'amber' + | 'yellow' + | 'lime' + | 'grass' + | 'green' + | 'jade' + | 'mint' + | 'turquoise' + | 'cyan' + | 'sky' + | 'blue' + | 'iris' + | 'violet' + | 'purple' + | 'plum' + | 'pink' + | 'bronze' + | 'gold' + | 'brown' + | 'gray'; + +export type ResendEmailStatus = + | 'SENT' + | 'DELIVERED' + | 'DELIVERY_DELAYED' + | 'COMPLAINED' + | 'BOUNCED' + | 'OPENED' + | 'CLICKED' + | 'SCHEDULED' + | 'QUEUED' + | 'FAILED' + | 'CANCELED' + | 'RECEIVED' + | 'SUPPRESSED'; + +export type EmailStatusGroup = 'reached' | 'failed' | 'inFlight' | 'other'; + +export const EMAIL_STATUS_GROUP_BY_STATUS: Record< + ResendEmailStatus, + EmailStatusGroup +> = { + DELIVERED: 'reached', + OPENED: 'reached', + CLICKED: 'reached', + BOUNCED: 'failed', + FAILED: 'failed', + COMPLAINED: 'failed', + SUPPRESSED: 'failed', + SENT: 'inFlight', + QUEUED: 'inFlight', + SCHEDULED: 'inFlight', + DELIVERY_DELAYED: 'inFlight', + CANCELED: 'other', + RECEIVED: 'other', +}; + +export type EmailStatusMeta = { + label: string; + color: ThemeColor; +}; + +export const EMAIL_STATUS_META_BY_STATUS: Record< + ResendEmailStatus, + EmailStatusMeta +> = { + SENT: { label: 'Sent', color: 'blue' }, + DELIVERED: { label: 'Delivered', color: 'green' }, + DELIVERY_DELAYED: { label: 'Delivery Delayed', color: 'yellow' }, + COMPLAINED: { label: 'Complained', color: 'orange' }, + BOUNCED: { label: 'Bounced', color: 'red' }, + OPENED: { label: 'Opened', color: 'turquoise' }, + CLICKED: { label: 'Clicked', color: 'sky' }, + SCHEDULED: { label: 'Scheduled', color: 'gray' }, + QUEUED: { label: 'Queued', color: 'gray' }, + FAILED: { label: 'Failed', color: 'red' }, + CANCELED: { label: 'Canceled', color: 'gray' }, + RECEIVED: { label: 'Received', color: 'blue' }, + SUPPRESSED: { label: 'Suppressed', color: 'red' }, +}; + +export const RESEND_EMAIL_STATUS_DISPLAY_ORDER: ReadonlyArray = + [ + 'DELIVERED', + 'OPENED', + 'CLICKED', + 'SENT', + 'QUEUED', + 'SCHEDULED', + 'DELIVERY_DELAYED', + 'BOUNCED', + 'COMPLAINED', + 'SUPPRESSED', + 'FAILED', + 'CANCELED', + 'RECEIVED', + ]; + +export const isResendEmailStatus = ( + value: string | null | undefined, +): value is ResendEmailStatus => + typeof value === 'string' && value in EMAIL_STATUS_GROUP_BY_STATUS; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/front-components/PersonResendEmailStats.front-component.tsx b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/front-components/PersonResendEmailStats.front-component.tsx new file mode 100644 index 0000000000..db550f6617 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/front-components/PersonResendEmailStats.front-component.tsx @@ -0,0 +1,265 @@ +import { defineFrontComponent } from 'twenty-sdk/define'; +import { + Callout, + H2Title, + IconAlertCircle, + IconMail, + IconRefresh, + themeCssVariables, +} from 'twenty-sdk/ui'; + +import { isDefined } from '@utils/is-defined'; + +import { PERSON_RESEND_EMAIL_STATS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; +import { + EMAIL_STATUS_META_BY_STATUS, + RESEND_EMAIL_STATUS_DISPLAY_ORDER, + type ThemeColor, +} from '@modules/resend/email-stats/constants/email-status-groups'; +import { usePersonResendEmailStats } from '@modules/resend/email-stats/hooks/usePersonResendEmailStats'; + +const getDeliverabilityColor = (rate: number): ThemeColor => { + if (rate >= 0.95) return 'green'; + if (rate >= 0.8) return 'yellow'; + return 'red'; +}; + +const formatRate = (rate: number): string => `${Math.round(rate * 100)}%`; + +const formatPercentage = (value: number, total: number): string => { + if (total === 0) return '0%'; + + const pct = (value / total) * 100; + + return pct >= 10 ? `${pct.toFixed(0)}%` : `${pct.toFixed(1)}%`; +}; + +type StatusPillProps = { + color: ThemeColor; + text: string; +}; + +const StatusPill = ({ color, text }: StatusPillProps) => ( + + {text} + +); + +const PROGRESS_BAR_HEIGHT = '8px'; + +type MiniProgressBarProps = { + value: number; + color: ThemeColor; +}; + +const MiniProgressBar = ({ value, color }: MiniProgressBarProps) => { + const clamped = Math.max(0, Math.min(100, value)); + + return ( +
+
+
+ ); +}; + +const getStyles = (): Record => ({ + container: { + fontFamily: themeCssVariables.font.family, + fontSize: themeCssVariables.font.size.sm, + color: themeCssVariables.font.color.primary, + display: 'flex', + flexDirection: 'column', + gap: themeCssVariables.spacing[3], + boxSizing: 'border-box', + }, + card: { + padding: themeCssVariables.spacing[3], + borderRadius: themeCssVariables.border.radius.md, + background: themeCssVariables.background.secondary, + border: `1px solid ${themeCssVariables.border.color.light}`, + display: 'flex', + flexDirection: 'column', + gap: themeCssVariables.spacing[2], + }, + cardHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: themeCssVariables.spacing[2], + }, + h2TitleNoMargin: { + display: 'flex', + marginBottom: `calc(-1 * ${themeCssVariables.spacing[4]})`, + }, + rateRow: { + display: 'flex', + alignItems: 'center', + gap: themeCssVariables.spacing[2], + color: themeCssVariables.font.color.secondary, + }, + rateMeta: { + fontSize: themeCssVariables.font.size.xs, + color: themeCssVariables.font.color.tertiary, + }, + legend: { + display: 'flex', + flexDirection: 'column', + gap: themeCssVariables.spacing[2], + }, + statusRow: { + display: 'grid', + gridTemplateColumns: 'minmax(120px, max-content) 1fr auto', + alignItems: 'center', + gap: themeCssVariables.spacing[2], + }, + statusCount: { + fontVariantNumeric: 'tabular-nums', + color: themeCssVariables.font.color.secondary, + whiteSpace: 'nowrap', + }, +}); + +const PersonResendEmailStats = () => { + const { stats, loading, error } = usePersonResendEmailStats(); + const styles = getStyles(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (isDefined(error)) { + return ( +
+ +
+ ); + } + + if (stats.total === 0) { + return ( +
+ +
+ ); + } + + const deliverabilityRate = stats.deliverabilityRate; + const deliverabilityDenominator = + stats.groupCounts.reached + stats.groupCounts.failed; + const rateColor = isDefined(deliverabilityRate) + ? getDeliverabilityColor(deliverabilityRate) + : 'gray'; + const rateLabel = isDefined(deliverabilityRate) + ? formatRate(deliverabilityRate) + : 'N/A'; + + return ( +
+
+
+
+ +
+ +
+ +
+ {stats.groupCounts.reached} delivered out of{' '} + {deliverabilityDenominator} concluded ({stats.total} total emails) +
+
+ +
+
+ +
+
+ {RESEND_EMAIL_STATUS_DISPLAY_ORDER.map((status) => { + const count = stats.countsByStatus[status]; + + if (count === 0) return null; + + const meta = EMAIL_STATUS_META_BY_STATUS[status]; + const percentage = (count / stats.total) * 100; + + return ( +
+ + + + {count} ({formatPercentage(count, stats.total)}) + +
+ ); + })} +
+
+
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: + PERSON_RESEND_EMAIL_STATS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + name: 'Person Resend Email Stats', + description: + "Shows the breakdown of a person's linked Resend emails by last event and computes the deliverability rate.", + component: PersonResendEmailStats, +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/hooks/usePersonResendEmailStats.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/hooks/usePersonResendEmailStats.ts new file mode 100644 index 0000000000..abbc707e51 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/hooks/usePersonResendEmailStats.ts @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { useRecordId } from 'twenty-sdk/front-component'; + +import { isDefined } from '@utils/is-defined'; + +import { computeEmailStats, type EmailStats } from '@modules/resend/email-stats/utils/compute-email-stats'; +import { extractConnection } from '@modules/resend/shared/utils/typed-client'; + +const PAGE_SIZE = 100; +const MAX_PAGES = 50; + +type ResendEmailLastEventNode = { + lastEvent?: string | null; +}; + +export type PersonResendEmailStatsState = { + stats: EmailStats; + loading: boolean; + error: string | null; +}; + +const buildEmptyStats = (): EmailStats => computeEmailStats([]); + +export const usePersonResendEmailStats = (): PersonResendEmailStatsState => { + const recordId = useRecordId(); + const [state, setState] = useState({ + stats: buildEmptyStats(), + loading: true, + error: null, + }); + + useEffect(() => { + if (!isDefined(recordId)) { + setState({ + stats: buildEmptyStats(), + loading: false, + error: 'No record ID', + }); + return; + } + + let cancelled = false; + + const load = async () => { + setState({ stats: buildEmptyStats(), loading: true, error: null }); + + try { + const client = new CoreApiClient(); + const collected: ResendEmailLastEventNode[] = []; + let afterCursor: string | undefined; + + for (let page = 0; page < MAX_PAGES; page += 1) { + const queryArgs: Record = { + filter: { personId: { eq: recordId } }, + first: PAGE_SIZE, + }; + + if (isDefined(afterCursor)) { + queryArgs.after = afterCursor; + } + + const queryResult = await client.query({ + resendEmails: { + __args: queryArgs, + pageInfo: { + hasNextPage: true, + endCursor: true, + }, + edges: { + node: { + lastEvent: true, + }, + }, + }, + }); + + const connection = extractConnection( + queryResult, + 'resendEmails', + ); + + for (const edge of connection.edges) { + collected.push(edge.node); + } + + const hasNextPage = connection.pageInfo?.hasNextPage ?? false; + const endCursor = connection.pageInfo?.endCursor; + + if (!hasNextPage || !isDefined(endCursor)) { + break; + } + + afterCursor = endCursor; + } + + if (!cancelled) { + setState({ + stats: computeEmailStats(collected), + loading: false, + error: null, + }); + } + } catch (fetchError) { + if (!cancelled) { + setState({ + stats: buildEmptyStats(), + loading: false, + error: + fetchError instanceof Error + ? fetchError.message + : String(fetchError), + }); + } + } + }; + + load(); + + return () => { + cancelled = true; + }; + }, [recordId]); + + return state; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/utils/__tests__/compute-email-stats.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/utils/__tests__/compute-email-stats.test.ts new file mode 100644 index 0000000000..c63052fd93 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/utils/__tests__/compute-email-stats.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { computeEmailStats } from '@modules/resend/email-stats/utils/compute-email-stats'; + +describe('computeEmailStats', () => { + it('returns zeroed counts and a null deliverability rate for an empty list', () => { + const stats = computeEmailStats([]); + + expect(stats.total).toBe(0); + expect(stats.deliverabilityRate).toBeNull(); + expect(stats.groupCounts).toEqual({ + reached: 0, + failed: 0, + inFlight: 0, + other: 0, + }); + expect(stats.countsByStatus.DELIVERED).toBe(0); + expect(stats.countsByStatus.BOUNCED).toBe(0); + }); + + it('returns a deliverability rate of 1 when all emails are delivered', () => { + const stats = computeEmailStats([ + { lastEvent: 'DELIVERED' }, + { lastEvent: 'DELIVERED' }, + { lastEvent: 'OPENED' }, + { lastEvent: 'CLICKED' }, + ]); + + expect(stats.total).toBe(4); + expect(stats.groupCounts.reached).toBe(4); + expect(stats.groupCounts.failed).toBe(0); + expect(stats.deliverabilityRate).toBe(1); + }); + + it('returns a deliverability rate of 0 when every email failed', () => { + const stats = computeEmailStats([ + { lastEvent: 'BOUNCED' }, + { lastEvent: 'FAILED' }, + { lastEvent: 'COMPLAINED' }, + { lastEvent: 'SUPPRESSED' }, + ]); + + expect(stats.total).toBe(4); + expect(stats.groupCounts.reached).toBe(0); + expect(stats.groupCounts.failed).toBe(4); + expect(stats.deliverabilityRate).toBe(0); + }); + + it('returns a null deliverability rate when only in-flight statuses are present', () => { + const stats = computeEmailStats([ + { lastEvent: 'SENT' }, + { lastEvent: 'QUEUED' }, + { lastEvent: 'SCHEDULED' }, + { lastEvent: 'DELIVERY_DELAYED' }, + ]); + + expect(stats.total).toBe(4); + expect(stats.groupCounts.inFlight).toBe(4); + expect(stats.deliverabilityRate).toBeNull(); + }); + + it('aggregates a mixed list correctly', () => { + const stats = computeEmailStats([ + { lastEvent: 'DELIVERED' }, + { lastEvent: 'DELIVERED' }, + { lastEvent: 'OPENED' }, + { lastEvent: 'BOUNCED' }, + { lastEvent: 'FAILED' }, + { lastEvent: 'SENT' }, + { lastEvent: 'CANCELED' }, + ]); + + expect(stats.total).toBe(7); + expect(stats.countsByStatus.DELIVERED).toBe(2); + expect(stats.countsByStatus.OPENED).toBe(1); + expect(stats.countsByStatus.BOUNCED).toBe(1); + expect(stats.countsByStatus.FAILED).toBe(1); + expect(stats.countsByStatus.SENT).toBe(1); + expect(stats.countsByStatus.CANCELED).toBe(1); + + expect(stats.groupCounts).toEqual({ + reached: 3, + failed: 2, + inFlight: 1, + other: 1, + }); + + expect(stats.deliverabilityRate).toBeCloseTo(3 / 5); + }); + + it('ignores unknown, null, and undefined statuses', () => { + const stats = computeEmailStats([ + { lastEvent: 'DELIVERED' }, + { lastEvent: 'BOUNCED' }, + { lastEvent: null }, + { lastEvent: undefined }, + { lastEvent: 'NOT_A_REAL_STATUS' }, + {}, + ]); + + expect(stats.total).toBe(2); + expect(stats.groupCounts.reached).toBe(1); + expect(stats.groupCounts.failed).toBe(1); + expect(stats.deliverabilityRate).toBe(0.5); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/utils/compute-email-stats.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/utils/compute-email-stats.ts new file mode 100644 index 0000000000..6fa73204d3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/email-stats/utils/compute-email-stats.ts @@ -0,0 +1,71 @@ +import { + EMAIL_STATUS_GROUP_BY_STATUS, + isResendEmailStatus, + type EmailStatusGroup, + type ResendEmailStatus, +} from '@modules/resend/email-stats/constants/email-status-groups'; + +export type EmailStatsInput = ReadonlyArray<{ + lastEvent?: string | null; +}>; + +export type EmailStats = { + total: number; + countsByStatus: Record; + groupCounts: Record; + deliverabilityRate: number | null; +}; + +const buildEmptyStatusCounts = (): Record => ({ + SENT: 0, + DELIVERED: 0, + DELIVERY_DELAYED: 0, + COMPLAINED: 0, + BOUNCED: 0, + OPENED: 0, + CLICKED: 0, + SCHEDULED: 0, + QUEUED: 0, + FAILED: 0, + CANCELED: 0, + RECEIVED: 0, + SUPPRESSED: 0, +}); + +const buildEmptyGroupCounts = (): Record => ({ + reached: 0, + failed: 0, + inFlight: 0, + other: 0, +}); + +export const computeEmailStats = (emails: EmailStatsInput): EmailStats => { + const countsByStatus = buildEmptyStatusCounts(); + const groupCounts = buildEmptyGroupCounts(); + let total = 0; + + for (const email of emails) { + const status = email.lastEvent; + + if (!isResendEmailStatus(status)) { + continue; + } + + countsByStatus[status] += 1; + groupCounts[EMAIL_STATUS_GROUP_BY_STATUS[status]] += 1; + total += 1; + } + + const deliverabilityDenominator = groupCounts.reached + groupCounts.failed; + const deliverabilityRate = + deliverabilityDenominator > 0 + ? groupCounts.reached / deliverabilityDenominator + : null; + + return { + total, + countsByStatus, + groupCounts, + deliverabilityRate, + }; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/components/HtmlPreview.tsx b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/components/HtmlPreview.tsx index a8f8ecbadd..1c0fe6d127 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/components/HtmlPreview.tsx +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/components/HtmlPreview.tsx @@ -1,27 +1,40 @@ import { isNonEmptyString } from '@sniptt/guards'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; +import { themeCssVariables } from 'twenty-sdk/ui'; type HtmlPreviewProps = { html: string | null | undefined; }; +// Styles are computed lazily inside the component body because the SDK +// mocks `twenty-sdk/ui` at manifest-build time, which leaves +// `themeCssVariables` undefined during static module evaluation. +const getStyles = (): Record => ({ + emptyState: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + padding: themeCssVariables.spacing[4], + fontFamily: themeCssVariables.font.family, + fontSize: themeCssVariables.font.size.sm, + color: themeCssVariables.font.color.tertiary, + background: themeCssVariables.background.secondary, + boxSizing: 'border-box', + }, + iframe: { + width: '100%', + height: '100%', + border: 'none', + background: themeCssVariables.background.primary, + }, +}); + export const HtmlPreview = ({ html }: HtmlPreviewProps) => { + const styles = getStyles(); + if (!isDefined(html) || !isNonEmptyString(html)) { - return ( -
- No HTML content available -
- ); + return
No HTML content available
; } return ( @@ -29,11 +42,7 @@ export const HtmlPreview = ({ html }: HtmlPreviewProps) => { srcDoc={html} sandbox="" title="Email HTML preview" - style={{ - width: '100%', - height: '100%', - border: 'none', - }} + style={styles.iframe} /> ); }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/components/RecordHtmlViewer.tsx b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/components/RecordHtmlViewer.tsx index dc2b3d96d4..d24f926556 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/components/RecordHtmlViewer.tsx +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/components/RecordHtmlViewer.tsx @@ -1,54 +1,73 @@ -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; +import { Callout, IconAlertCircle, themeCssVariables } from 'twenty-sdk/ui'; -import { HtmlPreview } from 'src/modules/resend/html-viewer/components/HtmlPreview'; -import { useRecordHtml } from 'src/modules/resend/html-viewer/hooks/useRecordHtml'; +import { HtmlPreview } from '@modules/resend/html-viewer/components/HtmlPreview'; +import { useRecordHtml } from '@modules/resend/html-viewer/hooks/useRecordHtml'; type RecordHtmlViewerProps = { objectName: string; loadingText: string; }; +// Styles are computed lazily inside the component body because the SDK +// mocks `twenty-sdk/ui` at manifest-build time, which leaves +// `themeCssVariables` undefined during static module evaluation. +const getStyles = (): Record => { + const stateContainer: React.CSSProperties = { + padding: themeCssVariables.spacing[4], + fontFamily: themeCssVariables.font.family, + height: '100%', + boxSizing: 'border-box', + }; + + return { + stateContainer, + loader: { + ...stateContainer, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: themeCssVariables.font.color.tertiary, + fontSize: themeCssVariables.font.size.sm, + }, + previewWrapper: { + width: '100%', + height: '100%', + background: themeCssVariables.background.secondary, + border: `1px solid ${themeCssVariables.border.color.light}`, + borderRadius: themeCssVariables.border.radius.md, + overflow: 'hidden', + boxSizing: 'border-box', + }, + }; +}; + export const RecordHtmlViewer = ({ objectName, loadingText, }: RecordHtmlViewerProps) => { const { html, loading, error } = useRecordHtml(objectName); + const styles = getStyles(); if (loading) { - return ( -
- {loadingText} -
- ); + return
{loadingText}
; } if (isDefined(error)) { return ( -
-
{error}
+
+
); } return ( -
+
); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/front-components/BroadcastHtmlViewer.front-component.tsx b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/front-components/BroadcastHtmlViewer.front-component.tsx new file mode 100644 index 0000000000..62ddc1554f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/front-components/BroadcastHtmlViewer.front-component.tsx @@ -0,0 +1,19 @@ +import { defineFrontComponent } from 'twenty-sdk/define'; + +import { RecordHtmlViewer } from '@modules/resend/html-viewer/components/RecordHtmlViewer'; +import { BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; + +const BroadcastHtmlViewer = () => ( + +); + +export default defineFrontComponent({ + universalIdentifier: + BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + name: 'Broadcast HTML Viewer', + description: 'Renders the HTML body of a Resend broadcast', + component: BroadcastHtmlViewer, +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/front-components/EmailBroadcastHtmlViewer.front-component.tsx b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/front-components/EmailBroadcastHtmlViewer.front-component.tsx new file mode 100644 index 0000000000..02008bc18d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/front-components/EmailBroadcastHtmlViewer.front-component.tsx @@ -0,0 +1,92 @@ +import { isDefined } from '@utils/is-defined'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { + Callout, + IconAlertCircle, + IconInfoCircle, + themeCssVariables, +} from 'twenty-sdk/ui'; + +import { EMAIL_BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; +import { HtmlPreview } from '@modules/resend/html-viewer/components/HtmlPreview'; +import { useRelatedBroadcastHtml } from '@modules/resend/html-viewer/hooks/useRelatedBroadcastHtml'; + +const getStyles = (): Record => { + const stateContainer: React.CSSProperties = { + padding: themeCssVariables.spacing[4], + fontFamily: themeCssVariables.font.family, + height: '100%', + boxSizing: 'border-box', + }; + + return { + stateContainer, + loader: { + ...stateContainer, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: themeCssVariables.font.color.tertiary, + fontSize: themeCssVariables.font.size.sm, + }, + previewWrapper: { + width: '100%', + height: '100%', + background: themeCssVariables.background.secondary, + border: `1px solid ${themeCssVariables.border.color.light}`, + borderRadius: themeCssVariables.border.radius.md, + overflow: 'hidden', + boxSizing: 'border-box', + }, + }; +}; + +const EmailBroadcastHtmlViewer = () => { + const { html, loading, error, hasBroadcast } = useRelatedBroadcastHtml(); + const styles = getStyles(); + + if (loading) { + return
Loading broadcast preview...
; + } + + if (isDefined(error)) { + return ( +
+ +
+ ); + } + + if (!hasBroadcast) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: + EMAIL_BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + name: 'Email Broadcast HTML Viewer', + description: + 'Renders the HTML body of the broadcast linked to a Resend email', + component: EmailBroadcastHtmlViewer, +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/front-components/EmailHtmlViewer.front-component.tsx b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/front-components/EmailHtmlViewer.front-component.tsx deleted file mode 100644 index deab6e853d..0000000000 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/front-components/EmailHtmlViewer.front-component.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { defineFrontComponent } from 'twenty-sdk/define'; - -import { RecordHtmlViewer } from 'src/modules/resend/html-viewer/components/RecordHtmlViewer'; -import { EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers'; - -const EmailHtmlViewer = () => ( - -); - -export default defineFrontComponent({ - universalIdentifier: EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, - name: 'Email HTML Viewer', - description: 'Renders the HTML body of a Resend email', - component: EmailHtmlViewer, -}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/front-components/TemplateHtmlViewer.front-component.tsx b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/front-components/TemplateHtmlViewer.front-component.tsx index a40d9b8ad4..ae62c69345 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/front-components/TemplateHtmlViewer.front-component.tsx +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/front-components/TemplateHtmlViewer.front-component.tsx @@ -1,7 +1,7 @@ import { defineFrontComponent } from 'twenty-sdk/define'; -import { RecordHtmlViewer } from 'src/modules/resend/html-viewer/components/RecordHtmlViewer'; -import { TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers'; +import { RecordHtmlViewer } from '@modules/resend/html-viewer/components/RecordHtmlViewer'; +import { TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; const TemplateHtmlViewer = () => ( { htmlBody: true, }, }) - .then((result) => { + .then((result: unknown) => { const record = (result as Record)[objectName] as | { htmlBody?: string | null } | undefined; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/hooks/useRelatedBroadcastHtml.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/hooks/useRelatedBroadcastHtml.ts new file mode 100644 index 0000000000..6b9ee5a7a5 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/html-viewer/hooks/useRelatedBroadcastHtml.ts @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react'; +import { useRecordId } from 'twenty-sdk/front-component'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { isDefined } from '@utils/is-defined'; + +type RelatedBroadcastHtmlState = { + html: string | null; + loading: boolean; + error: string | null; + hasBroadcast: boolean; +}; + +type ResendEmailWithBroadcast = { + broadcast?: { htmlBody?: string | null } | null; +}; + +export const useRelatedBroadcastHtml = (): RelatedBroadcastHtmlState => { + const recordId = useRecordId(); + const [state, setState] = useState({ + html: null, + loading: true, + error: null, + hasBroadcast: false, + }); + + useEffect(() => { + if (!isDefined(recordId)) { + setState({ + html: null, + loading: false, + error: 'No record ID', + hasBroadcast: false, + }); + return; + } + + setState({ html: null, loading: true, error: null, hasBroadcast: false }); + + new CoreApiClient() + .query({ + resendEmail: { + __args: { filter: { id: { eq: recordId } } }, + broadcast: { + htmlBody: true, + }, + }, + }) + .then((result: unknown) => { + const record = (result as Record).resendEmail as + | ResendEmailWithBroadcast + | null + | undefined; + + if (!isDefined(record)) { + setState({ + html: null, + loading: false, + error: 'Email not found', + hasBroadcast: false, + }); + return; + } + + if (!isDefined(record.broadcast)) { + setState({ + html: null, + loading: false, + error: null, + hasBroadcast: false, + }); + return; + } + + setState({ + html: record.broadcast.htmlBody ?? null, + loading: false, + error: null, + hasBroadcast: true, + }); + }) + .catch((fetchError: unknown) => { + setState({ + html: null, + loading: false, + error: + fetchError instanceof Error + ? fetchError.message + : String(fetchError), + hasBroadcast: false, + }); + }); + }, [recordId]); + + return state; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/components/ResendSyncStatus.tsx b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/components/ResendSyncStatus.tsx new file mode 100644 index 0000000000..b0103f70d7 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/components/ResendSyncStatus.tsx @@ -0,0 +1,259 @@ +import { isDefined } from '@utils/is-defined'; +import { useEffect, useState } from 'react'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { + Callout, + H2Title, + IconAlertCircle, + IconRefresh, + Status, + themeCssVariables, +} from 'twenty-sdk/ui'; + +import { extractConnection } from '@modules/resend/shared/utils/typed-client'; +import { RESEND_SYNC_CURSOR_STEPS } from '@modules/resend/sync/cursor/constants/resend-sync-cursor-steps'; +import type { SyncCursorStep } from '@modules/resend/sync/cursor/types/sync-cursor-step'; + +type CursorRowStatus = 'SUCCESS' | 'FAILED' | 'IN_PROGRESS'; + +type CursorRow = { + id: string; + step: SyncCursorStep; + cursor: string | null; + lastRunAt: string | null; + lastRunStatus: CursorRowStatus | null; +}; + +type FetchState = { + rows: CursorRow[]; + loading: boolean; + error: string | null; +}; + +type StatusThemeColor = 'green' | 'red' | 'orange' | 'gray'; + +const STATUS_COLOR_BY_RUN_STATUS: Record = { + SUCCESS: 'green', + FAILED: 'red', + IN_PROGRESS: 'orange', +}; + +const STATUS_LABEL_BY_RUN_STATUS: Record = { + SUCCESS: 'Success', + FAILED: 'Failed', + IN_PROGRESS: 'In progress', +}; + +const formatTimestamp = (value: string | null): string => { + if (!isDefined(value)) { + return '—'; + } + + const parsed = new Date(value); + + if (Number.isNaN(parsed.getTime())) { + return value; + } + + return parsed.toLocaleString(); +}; + +const formatStepLabel = (step: SyncCursorStep): string => + step + .toLowerCase() + .split('_') + .map((part) => + part.length > 0 ? part[0].toUpperCase() + part.slice(1) : part, + ) + .join(' '); + +const getStyles = (): Record => ({ + container: { + fontFamily: themeCssVariables.font.family, + fontSize: themeCssVariables.font.size.sm, + color: themeCssVariables.font.color.primary, + display: 'flex', + flexDirection: 'column', + gap: themeCssVariables.spacing[3], + }, + card: { + padding: themeCssVariables.spacing[3], + borderRadius: themeCssVariables.border.radius.md, + background: themeCssVariables.background.secondary, + border: `1px solid ${themeCssVariables.border.color.light}`, + display: 'flex', + flexDirection: 'column', + gap: themeCssVariables.spacing[2], + userSelect: 'text', + WebkitUserSelect: 'text', + cursor: 'text', + }, + cardHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: themeCssVariables.spacing[2], + }, + cursorCode: { + display: 'inline-block', + maxWidth: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + verticalAlign: 'bottom', + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: themeCssVariables.font.size.xs, + color: themeCssVariables.font.color.secondary, + background: themeCssVariables.background.transparent.light, + borderRadius: themeCssVariables.border.radius.sm, + padding: `0 ${themeCssVariables.spacing[1]}`, + }, + h2TitleNoMargin: { + display: 'flex', + marginBottom: `calc(-1 * ${themeCssVariables.spacing[4]})`, + }, + cardLine: { + fontSize: themeCssVariables.font.size.sm, + color: themeCssVariables.font.color.secondary, + display: 'flex', + alignItems: 'center', + gap: themeCssVariables.spacing[1], + }, +}); + +export const ResendSyncStatus = () => { + const [state, setState] = useState({ + rows: [], + loading: true, + error: null, + }); + + useEffect(() => { + let cancelled = false; + + const load = async () => { + try { + const client = new CoreApiClient(); + + const cursorResult = await client.query({ + resendSyncCursors: { + __args: { first: 50 }, + edges: { + node: { + id: true, + step: true, + cursor: true, + lastRunAt: true, + lastRunStatus: true, + }, + }, + }, + }); + + const connection = extractConnection( + cursorResult, + 'resendSyncCursors', + ); + + if (!cancelled) { + setState({ + rows: connection.edges.map((edge) => edge.node), + loading: false, + error: null, + }); + } + } catch (fetchError) { + if (!cancelled) { + setState({ + rows: [], + loading: false, + error: + fetchError instanceof Error + ? fetchError.message + : String(fetchError), + }); + } + } + }; + + load(); + + return () => { + cancelled = true; + }; + }, []); + + const styles = getStyles(); + + if (state.loading) { + return ( +
+ +
+ ); + } + + if (isDefined(state.error)) { + return ( +
+ +
+ ); + } + + const rowByStep = new Map(); + + for (const row of state.rows) { + rowByStep.set(row.step, row); + } + + return ( +
+ {RESEND_SYNC_CURSOR_STEPS.map((step) => { + const row = rowByStep.get(step); + const runStatus = row?.lastRunStatus ?? null; + const cursor = row?.cursor ?? null; + const lastRunAt = row?.lastRunAt ?? null; + const statusColor: StatusThemeColor = isDefined(runStatus) + ? STATUS_COLOR_BY_RUN_STATUS[runStatus] + : 'gray'; + const statusLabel = isDefined(runStatus) + ? STATUS_LABEL_BY_RUN_STATUS[runStatus] + : 'Not synced'; + + return ( +
+
+
+ +
+ +
+
+ Last run: {formatTimestamp(lastRunAt)} +
+ {isDefined(cursor) && cursor !== '' && ( +
+ Resume cursor: {cursor} +
+ )} +
+ ); + })} +
+ ); +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/constants/resend-sync-status-menu-item-name.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/constants/resend-sync-status-menu-item-name.ts new file mode 100644 index 0000000000..379e003b67 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/constants/resend-sync-status-menu-item-name.ts @@ -0,0 +1 @@ +export const RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME = 'Sync Status'; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/front-components/ResendSyncStatus.front-component.tsx b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/front-components/ResendSyncStatus.front-component.tsx new file mode 100644 index 0000000000..5563cbf7ce --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/front-components/ResendSyncStatus.front-component.tsx @@ -0,0 +1,12 @@ +import { defineFrontComponent } from 'twenty-sdk/define'; + +import { ResendSyncStatus } from '@modules/resend/manual-sync/components/ResendSyncStatus'; +import { RESEND_SYNC_STATUS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; + +export default defineFrontComponent({ + universalIdentifier: RESEND_SYNC_STATUS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + name: 'Resend sync status', + description: + 'Displays the latest run timestamp, status, and error for each Resend sync step', + component: ResendSyncStatus, +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/front-components/SyncResendData.front-component.tsx b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/front-components/SyncResendData.front-component.tsx index 5c8fcd9293..6654166e9b 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/front-components/SyncResendData.front-component.tsx +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/front-components/SyncResendData.front-component.tsx @@ -1,74 +1,82 @@ +import { isDefined } from '@utils/is-defined'; +import { CoreApiClient } from 'twenty-client-sdk/core'; import { MetadataApiClient } from 'twenty-client-sdk/metadata'; import { defineFrontComponent } from 'twenty-sdk/define'; -import { Command, enqueueSnackbar, updateProgress } from 'twenty-sdk/front-component'; -import { isDefined } from 'twenty-shared/utils'; +import { + AppPath, + Command, + enqueueSnackbar, + navigate, +} from 'twenty-sdk/front-component'; +import { APPLICATION_UNIVERSAL_IDENTIFIER } from '@constants/universal-identifiers'; +import { INITIAL_SYNC_MODE_ENV_VAR_NAME } from '@modules/resend/constants/sync-config'; import { SYNC_RESEND_DATA_COMMAND_UNIVERSAL_IDENTIFIER, SYNC_RESEND_DATA_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, - SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; +import { resolveSyncStatusPageLayoutId } from '@modules/resend/manual-sync/utils/resolve-sync-status-page-layout-id'; +import { resetAllSyncCursors } from '@modules/resend/sync/cursor/utils/reset-all-sync-cursors'; -const execute = async () => { - await updateProgress(0.1); - - const metadataClient = new MetadataApiClient(); - - const { findManyLogicFunctions } = await metadataClient.query({ - findManyLogicFunctions: { +const resolveApplicationId = async ( + metadataClient: MetadataApiClient, +): Promise => { + const { findManyApplications } = await metadataClient.query({ + findManyApplications: { id: true, universalIdentifier: true, }, }); - const syncFunction = findManyLogicFunctions.find( - (fn) => - fn.universalIdentifier === - SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + const match = findManyApplications.find( + (application: { universalIdentifier: string }) => + application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER, ); - if (!isDefined(syncFunction)) { - throw new Error('Sync logic function not found'); + if (!isDefined(match)) { + throw new Error('Twenty-for-Twenty application not found'); } - await updateProgress(0.3); + return match.id; +}; - const { executeOneLogicFunction } = await metadataClient.mutation({ - executeOneLogicFunction: { +const flipInitialSyncModeOn = async ( + metadataClient: MetadataApiClient, + applicationId: string, +): Promise => { + await metadataClient.mutation({ + updateOneApplicationVariable: { __args: { - input: { - id: syncFunction.id, - payload: {} as Record, - }, + key: INITIAL_SYNC_MODE_ENV_VAR_NAME, + value: 'true', + applicationId, }, - status: true, - error: true, }, }); +}; - if (executeOneLogicFunction.status !== 'SUCCESS') { - const rawMessage = - typeof executeOneLogicFunction.error?.errorMessage === 'string' - ? executeOneLogicFunction.error.errorMessage - : 'Sync logic function execution failed'; +const execute = async () => { + const metadataClient = new MetadataApiClient(); + const coreApiClient = new CoreApiClient(); - const isRateLimit = - rawMessage.toLowerCase().includes('rate_limit') || - rawMessage.toLowerCase().includes('rate limit'); + await resetAllSyncCursors(coreApiClient); - throw new Error( - isRateLimit - ? 'Sync failed: Resend API rate limit exceeded. Please try again later.' - : `Sync failed: ${rawMessage}`, - ); - } + const applicationId = await resolveApplicationId(metadataClient); - await updateProgress(1); + await flipInitialSyncModeOn(metadataClient, applicationId); + + const pageLayoutId = await resolveSyncStatusPageLayoutId( + metadataClient, + applicationId, + ); await enqueueSnackbar({ - message: 'Resend data sync completed', + message: + 'Sync cursors reset and initial sync triggered — it will run in the background.', variant: 'success', }); + + await navigate(AppPath.PageLayoutPage, { pageLayoutId }); }; const SyncResendData = () => ; @@ -76,7 +84,8 @@ const SyncResendData = () => ; export default defineFrontComponent({ universalIdentifier: SYNC_RESEND_DATA_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, name: 'Sync Resend Data', - description: 'Triggers a manual sync of all Resend data', + description: + 'Resets every Resend sync cursor and flips the application into initial sync mode so the scheduled sync handlers restart from scratch on their next run.', isHeadless: true, component: SyncResendData, command: { diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/utils/__tests__/resolve-sync-status-page-layout-id.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/utils/__tests__/resolve-sync-status-page-layout-id.test.ts new file mode 100644 index 0000000000..93275ced96 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/utils/__tests__/resolve-sync-status-page-layout-id.test.ts @@ -0,0 +1,112 @@ +import { MetadataApiClient } from 'twenty-client-sdk/metadata'; +import { describe, expect, it, vi } from 'vitest'; + +import { RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME } from '@modules/resend/manual-sync/constants/resend-sync-status-menu-item-name'; +import { resolveSyncStatusPageLayoutId } from '@modules/resend/manual-sync/utils/resolve-sync-status-page-layout-id'; + +type NavigationMenuItem = { + id: string; + applicationId: string | null; + type: string; + name: string | null; + pageLayoutId: string | null; +}; + +const makeClient = (navigationMenuItems: NavigationMenuItem[]) => { + const query = vi.fn(async () => ({ navigationMenuItems })); + + return { query } as unknown as MetadataApiClient; +}; + +const APPLICATION_ID = 'app-1'; +const PAGE_LAYOUT_ID = 'page-layout-1'; + +describe('resolveSyncStatusPageLayoutId', () => { + it('returns the pageLayoutId of the matching menu item', async () => { + const client = makeClient([ + { + id: 'item-1', + applicationId: APPLICATION_ID, + type: 'PAGE_LAYOUT', + name: RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME, + pageLayoutId: PAGE_LAYOUT_ID, + }, + ]); + + const result = await resolveSyncStatusPageLayoutId(client, APPLICATION_ID); + + expect(result).toBe(PAGE_LAYOUT_ID); + }); + + it('ignores menu items from a different application', async () => { + const client = makeClient([ + { + id: 'item-1', + applicationId: 'other-app', + type: 'PAGE_LAYOUT', + name: RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME, + pageLayoutId: 'other-page-layout', + }, + ]); + + await expect( + resolveSyncStatusPageLayoutId(client, APPLICATION_ID), + ).rejects.toThrow('Resend Sync Status page layout not found'); + }); + + it('ignores menu items with a different type', async () => { + const client = makeClient([ + { + id: 'item-1', + applicationId: APPLICATION_ID, + type: 'FOLDER', + name: RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME, + pageLayoutId: null, + }, + ]); + + await expect( + resolveSyncStatusPageLayoutId(client, APPLICATION_ID), + ).rejects.toThrow('Resend Sync Status page layout not found'); + }); + + it('ignores menu items with a different name', async () => { + const client = makeClient([ + { + id: 'item-1', + applicationId: APPLICATION_ID, + type: 'PAGE_LAYOUT', + name: 'Some Other Page', + pageLayoutId: PAGE_LAYOUT_ID, + }, + ]); + + await expect( + resolveSyncStatusPageLayoutId(client, APPLICATION_ID), + ).rejects.toThrow('Resend Sync Status page layout not found'); + }); + + it('throws when the matching menu item has a null pageLayoutId', async () => { + const client = makeClient([ + { + id: 'item-1', + applicationId: APPLICATION_ID, + type: 'PAGE_LAYOUT', + name: RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME, + pageLayoutId: null, + }, + ]); + + await expect( + resolveSyncStatusPageLayoutId(client, APPLICATION_ID), + ).rejects.toThrow('Resend Sync Status page layout not found'); + }); + + it('throws when the response contains no menu items', async () => { + const client = makeClient([]); + + await expect( + resolveSyncStatusPageLayoutId(client, APPLICATION_ID), + ).rejects.toThrow('Resend Sync Status page layout not found'); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/utils/resolve-sync-status-page-layout-id.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/utils/resolve-sync-status-page-layout-id.ts new file mode 100644 index 0000000000..86c3db5e6d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/manual-sync/utils/resolve-sync-status-page-layout-id.ts @@ -0,0 +1,39 @@ +import { isDefined } from '@utils/is-defined'; +import { MetadataApiClient } from 'twenty-client-sdk/metadata'; + +import { RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME } from '@modules/resend/manual-sync/constants/resend-sync-status-menu-item-name'; + +type NavigationMenuItem = { + applicationId: string | null; + type: string; + name: string | null; + pageLayoutId: string | null; +}; + +export const resolveSyncStatusPageLayoutId = async ( + metadataClient: MetadataApiClient, + applicationId: string, +): Promise => { + const { navigationMenuItems } = await metadataClient.query({ + navigationMenuItems: { + id: true, + name: true, + type: true, + applicationId: true, + pageLayoutId: true, + }, + }); + + const match = (navigationMenuItems as NavigationMenuItem[]).find( + (item) => + item.applicationId === applicationId && + item.type === 'PAGE_LAYOUT' && + item.name === RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME, + ); + + if (!isDefined(match) || !isDefined(match.pageLayoutId)) { + throw new Error('Resend Sync Status page layout not found'); + } + + return match.pageLayoutId; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/broadcast-on-resend-email.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/broadcast-on-resend-email.field.ts index 716c84df5a..11790b1b3d 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/broadcast-on-resend-email.field.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/broadcast-on-resend-email.field.ts @@ -3,7 +3,7 @@ import { RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER, RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; export default defineField({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/contact-on-resend-email.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/contact-on-resend-email.field.ts index d256f60991..a15e2b5362 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/contact-on-resend-email.field.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/contact-on-resend-email.field.ts @@ -3,7 +3,7 @@ import { RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER, RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; export default defineField({ @@ -20,5 +20,5 @@ export default defineField({ relationType: RelationType.MANY_TO_ONE, joinColumnName: 'contactId', }, - icon: 'IconAddressBook', + icon: 'IconUser', }); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/person-on-resend-contact.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/person-on-resend-contact.field.ts index 8d0fc29219..f99b1b2486 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/person-on-resend-contact.field.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/person-on-resend-contact.field.ts @@ -2,7 +2,7 @@ import { PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER, RESEND_CONTACTS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER, RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define'; export default defineField({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/person-on-resend-email.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/person-on-resend-email.field.ts index 9409288b8f..2d85536dd3 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/person-on-resend-email.field.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/person-on-resend-email.field.ts @@ -2,7 +2,7 @@ import { PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER, RESEND_EMAILS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define'; export default defineField({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-broadcasts-on-segment.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-broadcasts-on-segment.field.ts index cf701fdd1d..db3b195ab0 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-broadcasts-on-segment.field.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-broadcasts-on-segment.field.ts @@ -3,7 +3,7 @@ import { RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER, RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER, SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; export default defineField({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-broadcasts-on-topic.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-broadcasts-on-topic.field.ts new file mode 100644 index 0000000000..4e374cd9b9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-broadcasts-on-topic.field.ts @@ -0,0 +1,23 @@ +import { + RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER, + RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER, + TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER, +} from '@modules/resend/constants/universal-identifiers'; +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; + +export default defineField({ + universalIdentifier: RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'resendBroadcasts', + label: 'Broadcasts', + relationTargetObjectMetadataUniversalIdentifier: + RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, + icon: 'IconSpeakerphone', +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-contacts-on-person.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-contacts-on-person.field.ts index 3249cce371..ae0e95a8ab 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-contacts-on-person.field.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-contacts-on-person.field.ts @@ -2,7 +2,7 @@ import { PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER, RESEND_CONTACTS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER, RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define'; export default defineField({ @@ -19,5 +19,5 @@ export default defineField({ universalSettings: { relationType: RelationType.ONE_TO_MANY, }, - icon: 'IconAddressBook', + icon: 'IconUser', }); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-contacts-on-segment.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-contacts-on-segment.field.ts index 352d7fff26..253883f101 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-contacts-on-segment.field.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-contacts-on-segment.field.ts @@ -3,7 +3,7 @@ import { RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER, RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER, SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; export default defineField({ @@ -19,5 +19,5 @@ export default defineField({ universalSettings: { relationType: RelationType.ONE_TO_MANY, }, - icon: 'IconAddressBook', + icon: 'IconUser', }); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-emails-on-broadcast.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-emails-on-broadcast.field.ts index 334a9c5ffc..5e47351635 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-emails-on-broadcast.field.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-emails-on-broadcast.field.ts @@ -3,7 +3,7 @@ import { RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER, RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; export default defineField({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-emails-on-contact.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-emails-on-contact.field.ts index 6747e3a184..ca44f59aae 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-emails-on-contact.field.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-emails-on-contact.field.ts @@ -3,7 +3,7 @@ import { RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER, RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; export default defineField({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-emails-on-person.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-emails-on-person.field.ts index dfee059557..1e8f54c16e 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-emails-on-person.field.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/resend-emails-on-person.field.ts @@ -2,7 +2,7 @@ import { PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER, RESEND_EMAILS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define'; export default defineField({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/segment-on-resend-broadcast.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/segment-on-resend-broadcast.field.ts index ee31b6cad2..34ff7531d3 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/segment-on-resend-broadcast.field.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/segment-on-resend-broadcast.field.ts @@ -3,7 +3,7 @@ import { RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER, RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER, SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; export default defineField({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/segment-on-resend-contact.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/segment-on-resend-contact.field.ts index 4fe9309742..28eeb3b9f8 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/segment-on-resend-contact.field.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/segment-on-resend-contact.field.ts @@ -3,7 +3,7 @@ import { RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER, RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER, SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; export default defineField({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/topic-on-resend-broadcast.field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/topic-on-resend-broadcast.field.ts new file mode 100644 index 0000000000..a2614c4959 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/fields/topic-on-resend-broadcast.field.ts @@ -0,0 +1,24 @@ +import { + RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER, + RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER, + TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER, +} from '@modules/resend/constants/universal-identifiers'; +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; + +export default defineField({ + universalIdentifier: TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'topic', + label: 'Topic', + relationTargetObjectMetadataUniversalIdentifier: + RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: 'topicId', + }, + icon: 'IconHash', +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-broadcast-navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-broadcast-navigation-menu-item.ts index e913bd6c19..5fca41a065 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-broadcast-navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-broadcast-navigation-menu-item.ts @@ -2,9 +2,9 @@ import { RESEND_BROADCAST_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER, RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineNavigationMenuItem } from 'twenty-sdk/define'; -import { NavigationMenuItemType } from 'twenty-shared/types'; +import { NavigationMenuItemType } from 'twenty-sdk/define'; export default defineNavigationMenuItem({ universalIdentifier: diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-contact-navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-contact-navigation-menu-item.ts index 4c32a3b87d..7554d28b9b 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-contact-navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-contact-navigation-menu-item.ts @@ -2,14 +2,14 @@ import { RESEND_CONTACT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER, RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineNavigationMenuItem } from 'twenty-sdk/define'; -import { NavigationMenuItemType } from 'twenty-shared/types'; +import { NavigationMenuItemType } from 'twenty-sdk/define'; export default defineNavigationMenuItem({ universalIdentifier: RESEND_CONTACT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, name: 'Contacts', - icon: 'IconAddressBook', + icon: 'IconUser', position: 1, type: NavigationMenuItemType.VIEW, viewUniversalIdentifier: RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER, diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-email-navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-email-navigation-menu-item.ts index 31681a472a..565e73e916 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-email-navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-email-navigation-menu-item.ts @@ -2,9 +2,9 @@ import { RESEND_EMAIL_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER, RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineNavigationMenuItem } from 'twenty-sdk/define'; -import { NavigationMenuItemType } from 'twenty-shared/types'; +import { NavigationMenuItemType } from 'twenty-sdk/define'; export default defineNavigationMenuItem({ universalIdentifier: RESEND_EMAIL_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-folder-navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-folder-navigation-menu-item.ts index 291f7b668e..21c7ca6996 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-folder-navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-folder-navigation-menu-item.ts @@ -1,6 +1,6 @@ -import { RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers'; +import { RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; import { defineNavigationMenuItem } from 'twenty-sdk/define'; -import { NavigationMenuItemType } from 'twenty-shared/types'; +import { NavigationMenuItemType } from 'twenty-sdk/define'; export default defineNavigationMenuItem({ universalIdentifier: RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-segment-navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-segment-navigation-menu-item.ts index 13008bb647..b3bf7b77c2 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-segment-navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-segment-navigation-menu-item.ts @@ -2,9 +2,9 @@ import { RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, RESEND_SEGMENT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, RESEND_SEGMENT_VIEW_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineNavigationMenuItem } from 'twenty-sdk/define'; -import { NavigationMenuItemType } from 'twenty-shared/types'; +import { NavigationMenuItemType } from 'twenty-sdk/define'; export default defineNavigationMenuItem({ universalIdentifier: diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-sync-status-navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-sync-status-navigation-menu-item.ts new file mode 100644 index 0000000000..0379b4cd26 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-sync-status-navigation-menu-item.ts @@ -0,0 +1,21 @@ +import { + RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, + RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, + RESEND_SYNC_STATUS_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER, +} from '@modules/resend/constants/universal-identifiers'; +import { RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME } from '@modules/resend/manual-sync/constants/resend-sync-status-menu-item-name'; +import { defineNavigationMenuItem } from 'twenty-sdk/define'; +import { NavigationMenuItemType } from 'twenty-sdk/define'; + +export default defineNavigationMenuItem({ + universalIdentifier: + RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, + name: RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME, + icon: 'IconRefresh', + position: 100, + type: NavigationMenuItemType.PAGE_LAYOUT, + pageLayoutUniversalIdentifier: + RESEND_SYNC_STATUS_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER, + folderUniversalIdentifier: + RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-template-navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-template-navigation-menu-item.ts index 051ea32fe3..2e94dd6c2e 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-template-navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-template-navigation-menu-item.ts @@ -2,9 +2,9 @@ import { RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, RESEND_TEMPLATE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, RESEND_TEMPLATE_VIEW_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineNavigationMenuItem } from 'twenty-sdk/define'; -import { NavigationMenuItemType } from 'twenty-shared/types'; +import { NavigationMenuItemType } from 'twenty-sdk/define'; export default defineNavigationMenuItem({ universalIdentifier: diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-topic-navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-topic-navigation-menu-item.ts new file mode 100644 index 0000000000..08663901ee --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/navigation-menu-items/resend-topic-navigation-menu-item.ts @@ -0,0 +1,19 @@ +import { + RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, + RESEND_TOPIC_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, + RESEND_TOPIC_VIEW_UNIVERSAL_IDENTIFIER, +} from '@modules/resend/constants/universal-identifiers'; +import { defineNavigationMenuItem } from 'twenty-sdk/define'; +import { NavigationMenuItemType } from 'twenty-sdk/define'; + +export default defineNavigationMenuItem({ + universalIdentifier: + RESEND_TOPIC_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, + name: 'Topics', + icon: 'IconHash', + position: 5, + type: NavigationMenuItemType.VIEW, + viewUniversalIdentifier: RESEND_TOPIC_VIEW_UNIVERSAL_IDENTIFIER, + folderUniversalIdentifier: + RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-broadcast.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-broadcast.ts index c4977c909e..e6c6999ef9 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-broadcast.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-broadcast.ts @@ -1,6 +1,7 @@ import { BROADCAST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, BROADCAST_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER, + BROADCAST_HTML_FIELD_UNIVERSAL_IDENTIFIER, BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER, BROADCAST_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER, BROADCAST_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER, @@ -8,9 +9,10 @@ import { BROADCAST_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER, BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER, BROADCAST_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER, + BROADCAST_TEXT_FIELD_UNIVERSAL_IDENTIFIER, PREVIEW_TEXT_FIELD_UNIVERSAL_IDENTIFIER, RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineObject, FieldType } from 'twenty-sdk/define'; export default defineObject({ @@ -64,6 +66,22 @@ export default defineObject({ description: 'Preview text shown in email clients', icon: 'IconEye', }, + { + universalIdentifier: BROADCAST_HTML_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.TEXT, + name: 'htmlBody', + label: 'HTML body', + description: 'HTML content of the broadcast', + icon: 'IconFileText', + }, + { + universalIdentifier: BROADCAST_TEXT_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.TEXT, + name: 'textBody', + label: 'Text body', + description: 'Plain text content of the broadcast', + icon: 'IconAlignLeft', + }, { universalIdentifier: BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER, type: FieldType.SELECT, diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-contact.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-contact.ts index 1ebafdbdc8..d24f69ca14 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-contact.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-contact.ts @@ -6,7 +6,7 @@ import { NAME_FIELD_UNIVERSAL_IDENTIFIER, RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER, UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineObject, FieldType } from 'twenty-sdk/define'; export default defineObject({ @@ -16,7 +16,7 @@ export default defineObject({ labelSingular: 'Resend contact', labelPlural: 'Resend contacts', description: 'A contact from Resend', - icon: 'IconAddressBook', + icon: 'IconUser', labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, fields: [ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-email.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-email.ts index 6a20f2291e..de32439f06 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-email.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-email.ts @@ -5,16 +5,13 @@ import { EMAIL_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER, EMAIL_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER, FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER, - HTML_BODY_FIELD_UNIVERSAL_IDENTIFIER, LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER, REPLY_TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER, SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER, SUBJECT_FIELD_UNIVERSAL_IDENTIFIER, - TAGS_FIELD_UNIVERSAL_IDENTIFIER, - TEXT_BODY_FIELD_UNIVERSAL_IDENTIFIER, TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineObject, FieldType } from 'twenty-sdk/define'; export default defineObject({ @@ -52,22 +49,6 @@ export default defineObject({ description: 'Recipient email addresses', icon: 'IconUsers', }, - { - universalIdentifier: HTML_BODY_FIELD_UNIVERSAL_IDENTIFIER, - type: FieldType.TEXT, - name: 'htmlBody', - label: 'HTML body', - description: 'HTML content of the email', - icon: 'IconFileText', - }, - { - universalIdentifier: TEXT_BODY_FIELD_UNIVERSAL_IDENTIFIER, - type: FieldType.TEXT, - name: 'textBody', - label: 'Text body', - description: 'Plain text content of the email', - icon: 'IconAlignLeft', - }, { universalIdentifier: CC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER, type: FieldType.EMAILS, @@ -217,14 +198,6 @@ export default defineObject({ description: 'When the email is scheduled to be sent', icon: 'IconCalendarEvent', }, - { - universalIdentifier: TAGS_FIELD_UNIVERSAL_IDENTIFIER, - type: FieldType.RAW_JSON, - name: 'tags', - label: 'Tags', - description: 'Custom tags attached to the email', - icon: 'IconTag', - }, { universalIdentifier: EMAIL_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER, diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-segment.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-segment.ts index 1a33399c10..4ebe0a631e 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-segment.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-segment.ts @@ -4,7 +4,7 @@ import { SEGMENT_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER, SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER, SEGMENT_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineObject, FieldType } from 'twenty-sdk/define'; export default defineObject({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-sync-cursor.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-sync-cursor.ts new file mode 100644 index 0000000000..aa1c8479e5 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-sync-cursor.ts @@ -0,0 +1,125 @@ +import { + RESEND_SYNC_CURSOR_OBJECT_UNIVERSAL_IDENTIFIER, + SYNC_CURSOR_CURSOR_FIELD_UNIVERSAL_IDENTIFIER, + SYNC_CURSOR_LAST_RUN_AT_FIELD_UNIVERSAL_IDENTIFIER, + SYNC_CURSOR_LAST_RUN_STATUS_FIELD_UNIVERSAL_IDENTIFIER, + SYNC_CURSOR_STEP_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/modules/resend/constants/universal-identifiers'; +import { defineObject, FieldType } from 'twenty-sdk/define'; + +export default defineObject({ + universalIdentifier: RESEND_SYNC_CURSOR_OBJECT_UNIVERSAL_IDENTIFIER, + nameSingular: 'resendSyncCursor', + namePlural: 'resendSyncCursors', + labelSingular: 'Resend sync cursor', + labelPlural: 'Resend sync cursors', + description: + 'Persisted per-step cursor state for the Resend sync (technical object used to resume failed runs)', + icon: 'IconBookmark', + labelIdentifierFieldMetadataUniversalIdentifier: + SYNC_CURSOR_CURSOR_FIELD_UNIVERSAL_IDENTIFIER, + fields: [ + { + universalIdentifier: SYNC_CURSOR_STEP_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.SELECT, + name: 'step', + label: 'Step', + description: 'Sync step this cursor tracks', + icon: 'IconHash', + isUnique: true, + options: [ + { + id: 'c271f363-c680-446b-9318-06970b9af137', + value: 'SEGMENTS', + label: 'Segments', + position: 0, + color: 'gray', + }, + { + id: '7b63e182-8124-4085-ad17-9f2f3c9988e5', + value: 'TEMPLATES', + label: 'Templates', + position: 1, + color: 'blue', + }, + { + id: '0dd5ce53-94ec-4d1e-9ef5-5642d69922fe', + value: 'CONTACTS', + label: 'Contacts', + position: 2, + color: 'green', + }, + { + id: 'c9b5dc16-74f4-46d1-bfcd-010e46ab62e6', + value: 'EMAILS', + label: 'Emails', + position: 3, + color: 'purple', + }, + { + id: 'da6acca3-1341-42d3-a21c-aa18905d7536', + value: 'BROADCASTS', + label: 'Broadcasts', + position: 4, + color: 'orange', + }, + { + id: 'abec7a2d-20c5-4304-9356-7cf05db67a71', + value: 'TOPICS', + label: 'Topics', + position: 5, + color: 'pink', + }, + ], + }, + { + universalIdentifier: SYNC_CURSOR_CURSOR_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.TEXT, + name: 'cursor', + label: 'Cursor', + description: + 'Last successfully processed Resend ID; empty when not in progress', + icon: 'IconArrowBigRight', + }, + { + universalIdentifier: SYNC_CURSOR_LAST_RUN_AT_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.DATE_TIME, + name: 'lastRunAt', + label: 'Last run at', + description: 'Timestamp of the most recent sync run for this step', + icon: 'IconClock', + }, + { + universalIdentifier: + SYNC_CURSOR_LAST_RUN_STATUS_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.SELECT, + name: 'lastRunStatus', + label: 'Last run status', + description: 'Outcome of the most recent sync run for this step', + icon: 'IconActivity', + options: [ + { + id: 'b8e6cc9c-3b1f-4a6c-9f2c-77c5e1f0c8b1', + value: 'SUCCESS', + label: 'Success', + position: 0, + color: 'green', + }, + { + id: '6a8d7c44-cb5b-4d4d-b6d6-6e02e1f0aa12', + value: 'FAILED', + label: 'Failed', + position: 1, + color: 'red', + }, + { + id: 'c8d2bea1-0c5f-4b78-95cf-2f1de4d2f0a3', + value: 'IN_PROGRESS', + label: 'In progress', + position: 2, + color: 'yellow', + }, + ], + }, + ], +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-template.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-template.ts index 5a2c44e7b8..b27c082fd4 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-template.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-template.ts @@ -12,7 +12,7 @@ import { TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER, TEMPLATE_TEXT_FIELD_UNIVERSAL_IDENTIFIER, TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineObject, FieldType } from 'twenty-sdk/define'; export default defineObject({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-topic.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-topic.ts new file mode 100644 index 0000000000..ab6381499a --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/objects/resend-topic.ts @@ -0,0 +1,115 @@ +import { + RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER, + TOPIC_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + TOPIC_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER, + TOPIC_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER, + TOPIC_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER, + TOPIC_NAME_FIELD_UNIVERSAL_IDENTIFIER, + TOPIC_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER, + TOPIC_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER, +} from '@modules/resend/constants/universal-identifiers'; +import { defineObject, FieldType } from 'twenty-sdk/define'; + +export default defineObject({ + universalIdentifier: RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER, + nameSingular: 'resendTopic', + namePlural: 'resendTopics', + labelSingular: 'Resend topic', + labelPlural: 'Resend topics', + description: 'A subscription topic from Resend', + icon: 'IconHash', + labelIdentifierFieldMetadataUniversalIdentifier: + TOPIC_NAME_FIELD_UNIVERSAL_IDENTIFIER, + fields: [ + { + universalIdentifier: TOPIC_NAME_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.TEXT, + name: 'name', + label: 'Name', + description: 'Name of the topic', + icon: 'IconAbc', + }, + { + universalIdentifier: TOPIC_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.TEXT, + name: 'description', + label: 'Description', + description: 'Description of the topic', + icon: 'IconAlignLeft', + }, + { + universalIdentifier: TOPIC_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.SELECT, + name: 'defaultSubscription', + label: 'Default subscription', + description: 'Whether new contacts are subscribed to this topic by default', + icon: 'IconUserCheck', + options: [ + { + id: '5c7f7114-6b96-49b2-88cc-d43cd87ee110', + value: 'OPT_IN', + label: 'Opt in', + position: 0, + color: 'green', + }, + { + id: 'bff2b98d-7770-4b8f-be0e-ec0294e301df', + value: 'OPT_OUT', + label: 'Opt out', + position: 1, + color: 'gray', + }, + ], + }, + { + universalIdentifier: TOPIC_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.SELECT, + name: 'visibility', + label: 'Visibility', + description: 'Whether the topic is publicly visible to contacts', + icon: 'IconEye', + options: [ + { + id: 'bdb9d1c3-0cb3-474e-8504-5bc2e1f66731', + value: 'PUBLIC', + label: 'Public', + position: 0, + color: 'green', + }, + { + id: 'b7865f82-a387-48da-bb2d-5492d49ab6ad', + value: 'PRIVATE', + label: 'Private', + position: 1, + color: 'gray', + }, + ], + }, + { + universalIdentifier: TOPIC_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.TEXT, + name: 'resendId', + label: 'Resend ID', + description: 'Resend topic identifier', + icon: 'IconHash', + }, + { + universalIdentifier: TOPIC_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.DATE_TIME, + name: 'createdAt', + label: 'Created at', + description: 'When the topic was created', + icon: 'IconCalendar', + }, + { + universalIdentifier: + TOPIC_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.DATE_TIME, + name: 'lastSyncedFromResend', + label: 'Last synced from Resend', + description: + 'Timestamp of last inbound sync (used to prevent echo loops)', + icon: 'IconClock', + }, + ], +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-broadcast-record-page.page-layout.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-broadcast-record-page.page-layout.ts new file mode 100644 index 0000000000..a3ed6fcdcd --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-broadcast-record-page.page-layout.ts @@ -0,0 +1,143 @@ +import { + BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER, +} from '@modules/resend/constants/universal-identifiers'; +import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define'; + +export default definePageLayout({ + universalIdentifier: RESEND_BROADCAST_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER, + name: 'Resend Broadcast Record Page', + type: 'RECORD_PAGE', + objectUniversalIdentifier: RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER, + tabs: [ + { + universalIdentifier: + RESEND_BROADCAST_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER, + title: 'Home', + position: 50, + icon: 'IconHome', + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + widgets: [ + { + universalIdentifier: + RESEND_BROADCAST_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER, + title: 'Fields', + type: 'FIELDS', + configuration: { + configurationType: 'FIELDS', + }, + }, + ], + }, + { + universalIdentifier: + RESEND_BROADCAST_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER, + title: 'Preview', + position: 75, + icon: 'IconEye', + layoutMode: PageLayoutTabLayoutMode.CANVAS, + widgets: [ + { + universalIdentifier: + RESEND_BROADCAST_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER, + title: 'Broadcast Preview', + type: 'FRONT_COMPONENT', + configuration: { + configurationType: 'FRONT_COMPONENT', + frontComponentUniversalIdentifier: + BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + }, + }, + ], + }, + { + universalIdentifier: + RESEND_BROADCAST_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER, + title: 'Timeline', + position: 100, + icon: 'IconTimelineEvent', + layoutMode: PageLayoutTabLayoutMode.CANVAS, + widgets: [ + { + universalIdentifier: + RESEND_BROADCAST_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER, + title: 'Timeline', + type: 'TIMELINE', + configuration: { + configurationType: 'TIMELINE', + }, + }, + ], + }, + { + universalIdentifier: + RESEND_BROADCAST_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER, + title: 'Tasks', + position: 200, + icon: 'IconCheckbox', + layoutMode: PageLayoutTabLayoutMode.CANVAS, + widgets: [ + { + universalIdentifier: + RESEND_BROADCAST_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER, + title: 'Tasks', + type: 'TASKS', + configuration: { + configurationType: 'TASKS', + }, + }, + ], + }, + { + universalIdentifier: + RESEND_BROADCAST_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER, + title: 'Notes', + position: 300, + icon: 'IconNotes', + layoutMode: PageLayoutTabLayoutMode.CANVAS, + widgets: [ + { + universalIdentifier: + RESEND_BROADCAST_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER, + title: 'Notes', + type: 'NOTES', + configuration: { + configurationType: 'NOTES', + }, + }, + ], + }, + { + universalIdentifier: + RESEND_BROADCAST_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER, + title: 'Files', + position: 400, + icon: 'IconPaperclip', + layoutMode: PageLayoutTabLayoutMode.CANVAS, + widgets: [ + { + universalIdentifier: + RESEND_BROADCAST_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER, + title: 'Files', + type: 'FILES', + configuration: { + configurationType: 'FILES', + }, + }, + ], + }, + ], +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-email-record-page.page-layout.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-email-record-page.page-layout.ts index 37eea5d9ea..ec86f2c120 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-email-record-page.page-layout.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-email-record-page.page-layout.ts @@ -1,5 +1,5 @@ import { - EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + EMAIL_BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER, @@ -14,7 +14,7 @@ import { RESEND_EMAIL_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define'; export default definePageLayout({ @@ -52,12 +52,12 @@ export default definePageLayout({ { universalIdentifier: RESEND_EMAIL_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER, - title: 'Email Preview', + title: 'Broadcast Preview', type: 'FRONT_COMPONENT', configuration: { configurationType: 'FRONT_COMPONENT', frontComponentUniversalIdentifier: - EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + EMAIL_BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, }, }, ], diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-sync-status.page-layout.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-sync-status.page-layout.ts new file mode 100644 index 0000000000..1973d6248c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-sync-status.page-layout.ts @@ -0,0 +1,42 @@ +import { + RESEND_SYNC_STATUS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + RESEND_SYNC_STATUS_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER, + RESEND_SYNC_STATUS_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER, + RESEND_SYNC_STATUS_PAGE_WIDGET_UNIVERSAL_IDENTIFIER, +} from '@modules/resend/constants/universal-identifiers'; +import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define'; + +export default definePageLayout({ + universalIdentifier: RESEND_SYNC_STATUS_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER, + name: 'Resend Sync Status', + type: 'STANDALONE_PAGE', + tabs: [ + { + universalIdentifier: + RESEND_SYNC_STATUS_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER, + title: 'Sync Status', + position: 0, + icon: 'IconRefresh', + layoutMode: PageLayoutTabLayoutMode.CANVAS, + widgets: [ + { + universalIdentifier: + RESEND_SYNC_STATUS_PAGE_WIDGET_UNIVERSAL_IDENTIFIER, + title: 'Sync Status', + type: 'FRONT_COMPONENT', + configuration: { + configurationType: 'FRONT_COMPONENT', + frontComponentUniversalIdentifier: + RESEND_SYNC_STATUS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + }, + gridPosition: { + row: 0, + column: 0, + rowSpan: 12, + columnSpan: 12, + }, + }, + ], + }, + ], +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-template-record-page.page-layout.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-template-record-page.page-layout.ts index 16292744f1..12a0da65ab 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-template-record-page.page-layout.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/page-layouts/resend-template-record-page.page-layout.ts @@ -14,7 +14,7 @@ import { RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER, RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER, TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define'; export default definePageLayout({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-broadcast-view.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-broadcast-view.ts index ff5c9c8f1c..4b1e0fd9ec 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-broadcast-view.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-broadcast-view.ts @@ -14,10 +14,12 @@ import { RESEND_BROADCAST_VIEW_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER, RESEND_BROADCAST_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER, RESEND_BROADCAST_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER, + RESEND_BROADCAST_VIEW_TOPIC_FIELD_UNIVERSAL_IDENTIFIER, RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER, RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER, SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; + TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER, +} from '@modules/resend/constants/universal-identifiers'; import { defineView } from 'twenty-sdk/define'; export default defineView({ @@ -89,6 +91,15 @@ export default defineView({ size: 12, position: 6, }, + { + universalIdentifier: + RESEND_BROADCAST_VIEW_TOPIC_FIELD_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER, + isVisible: true, + size: 12, + position: 7, + }, { universalIdentifier: RESEND_BROADCAST_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER, @@ -96,7 +107,7 @@ export default defineView({ RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER, isVisible: true, size: 12, - position: 7, + position: 8, }, ], }); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-contact-view.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-contact-view.ts index d05f8f81af..88d8e017ac 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-contact-view.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-contact-view.ts @@ -15,14 +15,14 @@ import { RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER, SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER, UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineView } from 'twenty-sdk/define'; export default defineView({ universalIdentifier: RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER, name: 'Resend contacts', objectUniversalIdentifier: RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER, - icon: 'IconAddressBook', + icon: 'IconUser', position: 0, fields: [ { diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-email-view.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-email-view.ts index bd5e4b87d1..de860a7370 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-email-view.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-email-view.ts @@ -13,9 +13,11 @@ import { RESEND_EMAIL_VIEW_LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER, + RESEND_EMAIL_VIEW_TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER, RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER, SUBJECT_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; + TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER, +} from '@modules/resend/constants/universal-identifiers'; import { defineView } from 'twenty-sdk/define'; export default defineView({ @@ -35,31 +37,13 @@ export default defineView({ }, { universalIdentifier: - RESEND_EMAIL_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER, + RESEND_EMAIL_VIEW_TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER, fieldMetadataUniversalIdentifier: - FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER, + TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER, isVisible: true, size: 12, position: 1, }, - { - universalIdentifier: - RESEND_EMAIL_VIEW_LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER, - fieldMetadataUniversalIdentifier: - LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER, - isVisible: true, - size: 12, - position: 2, - }, - { - universalIdentifier: - RESEND_EMAIL_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, - fieldMetadataUniversalIdentifier: - EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, - isVisible: true, - size: 12, - position: 3, - }, { universalIdentifier: RESEND_EMAIL_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER, @@ -67,8 +51,35 @@ export default defineView({ PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER, isVisible: true, size: 12, + position: 2, + }, + { + universalIdentifier: + RESEND_EMAIL_VIEW_LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + isVisible: true, + size: 12, + position: 3, + }, + { + universalIdentifier: + RESEND_EMAIL_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER, + isVisible: true, + size: 12, position: 4, }, + { + universalIdentifier: + RESEND_EMAIL_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + isVisible: true, + size: 12, + position: 5, + }, { universalIdentifier: RESEND_EMAIL_VIEW_CONTACT_FIELD_UNIVERSAL_IDENTIFIER, @@ -76,7 +87,7 @@ export default defineView({ CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER, isVisible: true, size: 12, - position: 5, + position: 6, }, { universalIdentifier: @@ -85,7 +96,7 @@ export default defineView({ BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER, isVisible: true, size: 12, - position: 6, + position: 7, }, ], }); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-segment-view.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-segment-view.ts index 00922a73bb..ef414231b7 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-segment-view.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-segment-view.ts @@ -9,7 +9,7 @@ import { RESEND_SEGMENT_VIEW_UNIVERSAL_IDENTIFIER, SEGMENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineView } from 'twenty-sdk/define'; export default defineView({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-template-view.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-template-view.ts index 4b9df2e7ef..147ece0364 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-template-view.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-template-view.ts @@ -13,7 +13,7 @@ import { TEMPLATE_STATUS_FIELD_UNIVERSAL_IDENTIFIER, TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER, TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER, -} from 'src/modules/resend/constants/universal-identifiers'; +} from '@modules/resend/constants/universal-identifiers'; import { defineView } from 'twenty-sdk/define'; export default defineView({ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-topic-view.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-topic-view.ts new file mode 100644 index 0000000000..704ac2887b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/schema/views/resend-topic-view.ts @@ -0,0 +1,79 @@ +import { + RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER, + RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER, + RESEND_TOPIC_VIEW_BROADCASTS_FIELD_UNIVERSAL_IDENTIFIER, + RESEND_TOPIC_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + RESEND_TOPIC_VIEW_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER, + RESEND_TOPIC_VIEW_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER, + RESEND_TOPIC_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER, + RESEND_TOPIC_VIEW_UNIVERSAL_IDENTIFIER, + RESEND_TOPIC_VIEW_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER, + TOPIC_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + TOPIC_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER, + TOPIC_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER, + TOPIC_NAME_FIELD_UNIVERSAL_IDENTIFIER, + TOPIC_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER, +} from '@modules/resend/constants/universal-identifiers'; +import { defineView } from 'twenty-sdk/define'; + +export default defineView({ + universalIdentifier: RESEND_TOPIC_VIEW_UNIVERSAL_IDENTIFIER, + name: 'Resend topics', + objectUniversalIdentifier: RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER, + icon: 'IconHash', + position: 0, + fields: [ + { + universalIdentifier: RESEND_TOPIC_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: TOPIC_NAME_FIELD_UNIVERSAL_IDENTIFIER, + isVisible: true, + size: 12, + position: 0, + }, + { + universalIdentifier: + RESEND_TOPIC_VIEW_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + TOPIC_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER, + isVisible: true, + size: 12, + position: 1, + }, + { + universalIdentifier: + RESEND_TOPIC_VIEW_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + TOPIC_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER, + isVisible: true, + size: 12, + position: 2, + }, + { + universalIdentifier: + RESEND_TOPIC_VIEW_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + TOPIC_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER, + isVisible: true, + size: 12, + position: 3, + }, + { + universalIdentifier: + RESEND_TOPIC_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + TOPIC_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + isVisible: true, + size: 12, + position: 4, + }, + { + universalIdentifier: + RESEND_TOPIC_VIEW_BROADCASTS_FIELD_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER, + isVisible: true, + size: 12, + position: 5, + }, + ], +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/__tests__/find-resend-contacts-by-email.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/__tests__/find-resend-contacts-by-email.test.ts new file mode 100644 index 0000000000..57212cfb65 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/__tests__/find-resend-contacts-by-email.test.ts @@ -0,0 +1,91 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { findResendContactsByEmail } from '@modules/resend/shared/utils/find-resend-contacts-by-email'; + +describe('findResendContactsByEmail', () => { + const buildClient = ( + queryImpl: ReturnType, + ): CoreApiClient => ({ query: queryImpl }) as unknown as CoreApiClient; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns an empty map without querying when no emails are provided', async () => { + const query = vi.fn(); + + const result = await findResendContactsByEmail(buildClient(query), []); + + expect(result.size).toBe(0); + expect(query).not.toHaveBeenCalled(); + }); + + it('returns an empty map without querying when only nullish/empty emails are provided', async () => { + const query = vi.fn(); + + const result = await findResendContactsByEmail(buildClient(query), [ + null, + undefined, + '', + ]); + + expect(result.size).toBe(0); + expect(query).not.toHaveBeenCalled(); + }); + + it('queries with deduplicated normalized emails and indexes results by normalized primaryEmail', async () => { + const query = vi.fn(async () => ({ + resendContacts: { + edges: [ + { + node: { + id: 'contact-1', + personId: 'person-1', + email: { primaryEmail: 'Foo@Example.com' }, + }, + }, + { + node: { + id: 'contact-2', + personId: null, + email: { primaryEmail: 'bar@example.com' }, + }, + }, + { + node: { + id: 'contact-3', + personId: null, + email: { primaryEmail: null }, + }, + }, + ], + }, + })); + + const result = await findResendContactsByEmail(buildClient(query), [ + ' Foo@Example.com ', + 'foo@example.com', + 'bar@example.com', + ]); + + expect(query).toHaveBeenCalledTimes(1); + + const args = query.mock.calls[0][0]; + + expect(args.resendContacts.__args.filter.email.primaryEmail.in).toEqual([ + 'foo@example.com', + 'bar@example.com', + ]); + + expect(result.get('foo@example.com')).toEqual({ + id: 'contact-1', + personId: 'person-1', + }); + expect(result.get('bar@example.com')).toEqual({ + id: 'contact-2', + personId: null, + }); + expect(result.size).toBe(2); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/__tests__/for-each-page.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/__tests__/for-each-page.test.ts new file mode 100644 index 0000000000..ef12aeb146 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/__tests__/for-each-page.test.ts @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + forEachPage, + type ResendListFunction, +} from '@modules/resend/shared/utils/for-each-page'; + +type Item = { id: string }; + +type ListResponse = Awaited>>; + +const page = (ids: string[], hasMore: boolean): ListResponse => ({ + data: { data: ids.map((id) => ({ id })), has_more: hasMore }, + error: null, +}); + +const createMockListFunction = ( + pages: ListResponse[], +): { + listFunction: ResendListFunction; + calls: { limit: number; after?: string }[]; +} => { + const calls: { limit: number; after?: string }[] = []; + let index = 0; + + const listFunction: ResendListFunction = async ( + paginationParameters, + ) => { + calls.push(paginationParameters); + const result = pages[index] ?? page([], false); + + index++; + + return result; + }; + + return { listFunction, calls }; +}; + +beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('forEachPage', () => { + it('calls onPage with each page in order', async () => { + const { listFunction } = createMockListFunction([ + page(['a', 'b'], true), + page(['c'], false), + ]); + + const seen: { ids: string[]; pageNumber: number }[] = []; + + await forEachPage(listFunction, async (items, pageNumber) => { + seen.push({ ids: items.map((item) => item.id), pageNumber }); + }); + + expect(seen).toEqual([ + { ids: ['a', 'b'], pageNumber: 1 }, + { ids: ['c'], pageNumber: 2 }, + ]); + }); + + it('starts from options.startCursor when provided', async () => { + const { listFunction, calls } = createMockListFunction([ + page(['c', 'd'], false), + ]); + + await forEachPage(listFunction, async () => {}, 'items', { + startCursor: 'b', + }); + + expect(calls[0]).toEqual({ limit: 100, after: 'b' }); + }); + + it('omits after when startCursor is an empty string', async () => { + const { listFunction, calls } = createMockListFunction([ + page(['a'], false), + ]); + + await forEachPage(listFunction, async () => {}, 'items', { + startCursor: '', + }); + + expect(calls[0]).toEqual({ limit: 100 }); + }); + + it('calls onCursorAdvance after each successful page with the last item id', async () => { + const { listFunction } = createMockListFunction([ + page(['a', 'b'], true), + page(['c', 'd'], false), + ]); + + const advances: { cursor: string }[] = []; + + await forEachPage(listFunction, async () => {}, 'items', { + onCursorAdvance: async (cursor) => { + advances.push({ cursor }); + }, + }); + + expect(advances).toEqual([{ cursor: 'b' }, { cursor: 'd' }]); + }); + + it('does not call onCursorAdvance when onPage throws', async () => { + const { listFunction } = createMockListFunction([ + page(['a'], true), + page(['b'], false), + ]); + const onCursorAdvance = vi.fn(async () => {}); + + await expect( + forEachPage( + listFunction, + async () => { + throw new Error('boom'); + }, + 'items', + { onCursorAdvance }, + ), + ).rejects.toThrow('boom'); + + expect(onCursorAdvance).not.toHaveBeenCalled(); + }); + + it('persists cursor for the final page even when has_more is false', async () => { + const { listFunction } = createMockListFunction([page(['a', 'b'], false)]); + const onCursorAdvance = vi.fn(async () => {}); + + await forEachPage(listFunction, async () => {}, 'items', { + onCursorAdvance, + }); + + expect(onCursorAdvance).toHaveBeenCalledTimes(1); + expect(onCursorAdvance).toHaveBeenCalledWith('b'); + }); + + it('continues past pages that return ok=false and advances the cursor over them', async () => { + const { listFunction, calls } = createMockListFunction([ + page(['a', 'b'], true), + page(['c', 'd'], true), + page(['e', 'f'], false), + ]); + + const onCursorAdvance = vi.fn(async () => {}); + const seenPages: string[][] = []; + + await forEachPage( + listFunction, + async (items, pageNumber) => { + seenPages.push(items.map((item) => item.id)); + + return { ok: pageNumber !== 2, errors: ['boom'] }; + }, + 'items', + { onCursorAdvance }, + ); + + expect(seenPages).toEqual([ + ['a', 'b'], + ['c', 'd'], + ['e', 'f'], + ]); + expect(calls).toHaveLength(3); + expect(onCursorAdvance).toHaveBeenCalledTimes(3); + expect(onCursorAdvance).toHaveBeenLastCalledWith('f'); + }); + + it('treats a void onPage return as ok and advances the cursor', async () => { + const { listFunction } = createMockListFunction([page(['a'], false)]); + const onCursorAdvance = vi.fn(async () => {}); + + await forEachPage(listFunction, async () => {}, 'items', { + onCursorAdvance, + }); + + expect(onCursorAdvance).toHaveBeenCalledWith('a'); + }); + + it('throws when the cursor does not advance across pages', async () => { + const { listFunction } = createMockListFunction([ + page(['a'], true), + page(['a'], true), + ]); + + await expect( + forEachPage(listFunction, async () => {}, 'segments'), + ).rejects.toThrow(/Resend list\[segments\] cursor stuck at a/); + }); + + it('stops requesting additional pages once deadlineAtMs is reached', async () => { + const { listFunction, calls } = createMockListFunction([ + page(['a', 'b'], true), + page(['c', 'd'], true), + page(['e', 'f'], true), + ]); + + const onCursorAdvance = vi.fn(async () => {}); + const seenPages: string[][] = []; + + let now = 1_000; + const dateNowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + + await forEachPage( + listFunction, + async (items) => { + seenPages.push(items.map((item) => item.id)); + now += 1_000; + }, + 'items', + { + onCursorAdvance, + deadlineAtMs: 2_500, + }, + ); + + expect(seenPages).toEqual([ + ['a', 'b'], + ['c', 'd'], + ]); + expect(calls).toHaveLength(2); + expect(onCursorAdvance).toHaveBeenCalledTimes(2); + expect(onCursorAdvance).toHaveBeenLastCalledWith('d'); + + dateNowSpy.mockRestore(); + }); + + it('still processes the first page even if the deadline is already past at start', async () => { + const { listFunction, calls } = createMockListFunction([ + page(['a'], true), + page(['b'], false), + ]); + + const onCursorAdvance = vi.fn(async () => {}); + const seenPages: string[][] = []; + + const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(10_000); + + await forEachPage( + listFunction, + async (items) => { + seenPages.push(items.map((item) => item.id)); + }, + 'items', + { + onCursorAdvance, + deadlineAtMs: 0, + }, + ); + + expect(seenPages).toEqual([['a']]); + expect(calls).toHaveLength(1); + expect(onCursorAdvance).toHaveBeenCalledTimes(1); + expect(onCursorAdvance).toHaveBeenCalledWith('a'); + + dateNowSpy.mockRestore(); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/__tests__/to-emails-field.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/__tests__/to-emails-field.test.ts new file mode 100644 index 0000000000..688e0ec8e9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/__tests__/to-emails-field.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { toEmailsField } from '@modules/resend/shared/utils/to-emails-field'; + +describe('toEmailsField', () => { + it('returns a plain email unchanged', () => { + expect(toEmailsField('thomas@mail.twenty.com')).toEqual({ + primaryEmail: 'thomas@mail.twenty.com', + additionalEmails: null, + }); + }); + + it('extracts the email from "Name " format', () => { + expect(toEmailsField('Thomas ')).toEqual({ + primaryEmail: 'thomas@mail.twenty.com', + additionalEmails: null, + }); + }); + + it('extracts the email from a quoted display name', () => { + expect(toEmailsField('"Last, First" ')).toEqual({ + primaryEmail: 'thomas@mail.twenty.com', + additionalEmails: null, + }); + }); + + it('normalizes each entry of an array of mixed formats', () => { + expect( + toEmailsField([ + 'Alice ', + 'bob@example.com', + '"Carol C." ', + ]), + ).toEqual({ + primaryEmail: 'alice@example.com', + additionalEmails: ['bob@example.com', 'carol@example.com'], + }); + }); + + it('returns empty primaryEmail for null', () => { + expect(toEmailsField(null)).toEqual({ + primaryEmail: '', + additionalEmails: null, + }); + }); + + it('returns empty primaryEmail for undefined', () => { + expect(toEmailsField(undefined)).toEqual({ + primaryEmail: '', + additionalEmails: null, + }); + }); + + it('returns empty primaryEmail for an empty string', () => { + expect(toEmailsField('')).toEqual({ + primaryEmail: '', + additionalEmails: null, + }); + }); + + it('trims surrounding whitespace from a plain email', () => { + expect(toEmailsField(' thomas@mail.twenty.com ')).toEqual({ + primaryEmail: 'thomas@mail.twenty.com', + additionalEmails: null, + }); + }); + + it('lowercases a plain email', () => { + expect(toEmailsField('Thomas@Mail.Twenty.Com')).toEqual({ + primaryEmail: 'thomas@mail.twenty.com', + additionalEmails: null, + }); + }); + + it('lowercases the address inside the "Name " format', () => { + expect(toEmailsField('Thomas ')).toEqual({ + primaryEmail: 'thomas@mail.twenty.com', + additionalEmails: null, + }); + }); + + it('lowercases each entry of an array', () => { + expect( + toEmailsField([ + 'Alice ', + 'BOB@example.com', + '"Carol C." ', + ]), + ).toEqual({ + primaryEmail: 'alice@example.com', + additionalEmails: ['bob@example.com', 'carol@example.com'], + }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/__tests__/with-rate-limit-retry.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/__tests__/with-rate-limit-retry.test.ts new file mode 100644 index 0000000000..8f701cee0e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/__tests__/with-rate-limit-retry.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { withRateLimitRetry } from '@modules/resend/shared/utils/with-rate-limit-retry'; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('withRateLimitRetry', () => { + it('returns the resolved value when no error is present', async () => { + const result = await withRateLimitRetry(async () => ({ + data: { ok: true }, + error: null, + })); + + expect(result).toEqual({ data: { ok: true }, error: null }); + }); + + it('retries on a Resend-shape rate-limit error response and eventually returns success', async () => { + const fn = vi + .fn() + .mockResolvedValueOnce({ + data: null, + error: { message: 'Too Many Requests', name: 'rate_limit_exceeded' }, + }) + .mockResolvedValueOnce({ data: { ok: true }, error: null }); + + const result = await withRateLimitRetry(fn, { + channel: 'test-error-response', + }); + + expect(fn).toHaveBeenCalledTimes(2); + expect(result).toEqual({ data: { ok: true }, error: null }); + }, 15_000); + + it('retries on a thrown rate-limit error', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('rate limit hit')) + .mockResolvedValueOnce({ data: 'ok', error: null }); + + const result = await withRateLimitRetry(fn, { channel: 'test-thrown' }); + + expect(fn).toHaveBeenCalledTimes(2); + expect(result).toEqual({ data: 'ok', error: null }); + }, 15_000); + + it('rethrows non-rate-limit errors immediately', async () => { + const fn = vi.fn().mockRejectedValue(new Error('schema mismatch')); + + await expect( + withRateLimitRetry(fn, { channel: 'test-rethrow' }), + ).rejects.toThrow('schema mismatch'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('uses independent throttle timestamps per channel', async () => { + let counterA = 0; + let counterB = 0; + + const fnA = vi.fn(async () => ({ data: ++counterA, error: null })); + const fnB = vi.fn(async () => ({ data: ++counterB, error: null })); + + await Promise.all([ + withRateLimitRetry(fnA, { channel: 'channel-a' }), + withRateLimitRetry(fnB, { channel: 'channel-b' }), + ]); + + expect(fnA).toHaveBeenCalledTimes(1); + expect(fnB).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/fetch-all-paginated.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/fetch-all-paginated.test.ts deleted file mode 100644 index c71b5cf49d..0000000000 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/fetch-all-paginated.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { - fetchAllPaginated, - type ResendListFn, -} from 'src/modules/resend/shared/utils/fetch-all-paginated'; - -type Item = { id: string }; - -type ListResponse = Awaited>>; - -const page = (ids: string[], hasMore: boolean): ListResponse => ({ - data: { data: ids.map((id) => ({ id })), has_more: hasMore }, - error: null, -}); - -const makeListFn = ( - pages: ListResponse[], -): { fn: ResendListFn; calls: { limit: number; after?: string }[] } => { - const calls: { limit: number; after?: string }[] = []; - let index = 0; - - const fn: ResendListFn = async (params) => { - calls.push(params); - const result = pages[index] ?? page([], false); - - index++; - - return result; - }; - - return { fn, calls }; -}; - -beforeEach(() => { - vi.spyOn(console, 'warn').mockImplementation(() => {}); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('fetchAllPaginated', () => { - it('returns items from a single page when has_more is false', async () => { - const { fn, calls } = makeListFn([page(['a', 'b', 'c'], false)]); - - const result = await fetchAllPaginated(fn); - - expect(result.map((item) => item.id)).toEqual(['a', 'b', 'c']); - expect(calls).toHaveLength(1); - expect(calls[0]).toEqual({ limit: 100 }); - }); - - it('follows cursor across pages and concatenates results', async () => { - const { fn, calls } = makeListFn([ - page(['a', 'b'], true), - page(['c', 'd'], true), - page(['e'], false), - ]); - - const result = await fetchAllPaginated(fn); - - expect(result.map((item) => item.id)).toEqual(['a', 'b', 'c', 'd', 'e']); - expect(calls).toEqual([ - { limit: 100 }, - { limit: 100, after: 'b' }, - { limit: 100, after: 'd' }, - ]); - }); - - it('stops when a page returns an empty array', async () => { - const { fn, calls } = makeListFn([ - page(['a'], true), - page([], true), - page(['b'], false), - ]); - - const result = await fetchAllPaginated(fn); - - expect(result.map((item) => item.id)).toEqual(['a']); - expect(calls).toHaveLength(2); - }); - - it('stops when data is null', async () => { - const { fn } = makeListFn([ - page(['a'], true), - { data: null, error: null }, - ]); - - const result = await fetchAllPaginated(fn); - - expect(result.map((item) => item.id)).toEqual(['a']); - }); - - it('includes label and cursor in the error message when listFn returns an error', async () => { - const fn: ResendListFn = async (params) => { - if (params.after === 'a') { - return { data: null, error: { code: 'boom' } }; - } - - return page(['a'], true); - }; - - await expect(fetchAllPaginated(fn, 'broadcasts')).rejects.toThrow( - /Resend list\[broadcasts\] failed at cursor=a: .*boom/, - ); - }); - - it('throws when the cursor does not advance', async () => { - const { fn } = makeListFn([page(['a'], true), page(['a'], true)]); - - await expect(fetchAllPaginated(fn, 'segments')).rejects.toThrow( - /Resend list\[segments\] cursor stuck at a/, - ); - }); -}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/fetch-all-paginated.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/fetch-all-paginated.ts deleted file mode 100644 index a6f441cdd4..0000000000 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/fetch-all-paginated.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { isDefined } from 'twenty-shared/utils'; - -import { withRateLimitRetry } from 'src/modules/resend/shared/utils/with-rate-limit-retry'; - -const PAGE_SIZE = 100; - -export type ResendListFn = (params: { - limit: number; - after?: string; -}) => Promise<{ - data: { data: T[]; has_more: boolean } | null; - error: unknown; -}>; - -export const fetchAllPaginated = async ( - listFn: ResendListFn, - label = 'items', -): Promise => { - const items: T[] = []; - let cursor: string | undefined; - - while (true) { - const params = { - limit: PAGE_SIZE, - ...(isDefined(cursor) && { after: cursor }), - }; - const response = await withRateLimitRetry(() => listFn(params)); - - if (isDefined(response.error)) { - throw new Error( - `Resend list[${label}] failed at cursor=${cursor ?? 'start'}: ${JSON.stringify(response.error)}`, - ); - } - - const page = response.data; - - if (!isDefined(page) || page.data.length === 0) break; - - items.push(...page.data); - - if (!page.has_more) break; - - const nextCursor = page.data[page.data.length - 1].id; - - if (nextCursor === cursor) { - throw new Error(`Resend list[${label}] cursor stuck at ${nextCursor}`); - } - - cursor = nextCursor; - } - - return items; -}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-or-create-person.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-or-create-person.ts index 74b6b61590..145d19d874 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-or-create-person.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-or-create-person.ts @@ -1,6 +1,6 @@ import { isNonEmptyString } from '@sniptt/guards'; import { CoreApiClient } from 'twenty-client-sdk/core'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; type PersonName = { firstName?: string; @@ -11,15 +11,30 @@ type PeopleConnection = { edges: Array<{ node: { id: string } }>; }; -export const findOrCreatePerson = async ( - client: CoreApiClient, - email: string | undefined | null, - name?: PersonName, -): Promise => { - if (!isNonEmptyString(email)) { - return undefined; - } +const isUniqueViolationError = (error: unknown): boolean => { + const text = + error instanceof Error + ? error.message + : typeof error === 'object' && error !== null + ? JSON.stringify(error) + : typeof error === 'string' + ? error + : ''; + const lower = text.toLowerCase(); + return ( + lower.includes('duplicate') || + lower.includes('unique constraint') || + lower.includes('uniqueness') || + lower.includes('already exists') || + lower.includes('violates unique') + ); +}; + +const findPersonByEmail = async ( + client: CoreApiClient, + email: string, +): Promise => { const { people } = await client.query({ people: { edges: { node: { id: true } }, @@ -34,29 +49,54 @@ export const findOrCreatePerson = async ( }, }); - const existingPersonId = (people as PeopleConnection | undefined)?.edges[0] - ?.node?.id; + return (people as PeopleConnection | undefined)?.edges[0]?.node?.id; +}; + +export const findOrCreatePerson = async ( + client: CoreApiClient, + email: string | undefined | null, + name?: PersonName, +): Promise => { + if (!isNonEmptyString(email)) { + return undefined; + } + + const existingPersonId = await findPersonByEmail(client, email); if (isDefined(existingPersonId)) { return existingPersonId; } - const { createPerson } = await client.mutation({ - createPerson: { - __args: { - data: { - name: { - firstName: name?.firstName ?? '', - lastName: name?.lastName ?? '', - }, - emails: { - primaryEmail: email, + try { + const { createPerson } = await client.mutation({ + createPerson: { + __args: { + data: { + name: { + firstName: name?.firstName ?? '', + lastName: name?.lastName ?? '', + }, + emails: { + primaryEmail: email, + }, }, }, + id: true, }, - id: true, - }, - }); + }); - return (createPerson as { id: string } | undefined)?.id; + return (createPerson as { id: string } | undefined)?.id; + } catch (createError) { + if (!isUniqueViolationError(createError)) { + throw createError; + } + + const raceWinnerId = await findPersonByEmail(client, email); + + if (isDefined(raceWinnerId)) { + return raceWinnerId; + } + + throw createError; + } }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-people-by-email.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-people-by-email.ts new file mode 100644 index 0000000000..9aa1802590 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-people-by-email.ts @@ -0,0 +1,56 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +type PeopleConnection = { + edges: Array<{ + node: { id: string; emails?: { primaryEmail?: string | null } | null }; + }>; +}; + +const normalize = (email: string): string => email.trim().toLowerCase(); + +export const findPeopleByEmail = async ( + client: CoreApiClient, + emails: ReadonlyArray, +): Promise> => { + const personIdByEmail = new Map(); + + const emailKeys = Array.from( + new Set( + emails + .filter((email): email is string => isNonEmptyString(email)) + .map(normalize), + ), + ); + + if (emailKeys.length === 0) return personIdByEmail; + + const { people } = await client.query({ + people: { + __args: { + filter: { + emails: { + primaryEmail: { in: emailKeys }, + }, + }, + first: emailKeys.length, + }, + edges: { + node: { + id: true, + emails: { primaryEmail: true }, + }, + }, + }, + }); + + for (const edge of (people as PeopleConnection | undefined)?.edges ?? []) { + const primaryEmail = edge.node.emails?.primaryEmail; + + if (isNonEmptyString(primaryEmail)) { + personIdByEmail.set(normalize(primaryEmail), edge.node.id); + } + } + + return personIdByEmail; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-record-by-resend-id.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-record-by-resend-id.ts index 5900608371..621edf2f89 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-record-by-resend-id.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-record-by-resend-id.ts @@ -1,8 +1,8 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; -type ConnectionResult = { - edges: Array<{ node: { id: string; resendId: string } }>; -}; +import { extractConnection } from '@modules/resend/shared/utils/typed-client'; + +type ResendIdNode = { id: string; resendId: string }; export const findRecordByResendId = async ( client: CoreApiClient, @@ -26,9 +26,7 @@ export const findRecordByResendId = async ( }, }); - const connection = (result as Record)[objectNamePlural] as - | ConnectionResult - | undefined; + const connection = extractConnection(result, objectNamePlural); - return connection?.edges[0]?.node.id; + return connection.edges[0]?.node.id; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-resend-contacts-by-email.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-resend-contacts-by-email.ts new file mode 100644 index 0000000000..9544eb7dc1 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-resend-contacts-by-email.ts @@ -0,0 +1,72 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { extractConnection } from '@modules/resend/shared/utils/typed-client'; + +export type ResendContactLookup = { + id: string; + personId: string | null; +}; + +type ResendContactNode = { + id: string; + personId?: string | null; + email?: { primaryEmail?: string | null } | null; +}; + +const normalize = (email: string): string => email.trim().toLowerCase(); + +export const findResendContactsByEmail = async ( + client: CoreApiClient, + emails: ReadonlyArray, +): Promise> => { + const contactByEmail = new Map(); + + const emailKeys = Array.from( + new Set( + emails + .filter((email): email is string => isNonEmptyString(email)) + .map(normalize), + ), + ); + + if (emailKeys.length === 0) return contactByEmail; + + const result = await client.query({ + resendContacts: { + __args: { + filter: { + email: { + primaryEmail: { in: emailKeys }, + }, + }, + first: emailKeys.length, + }, + edges: { + node: { + id: true, + personId: true, + email: { primaryEmail: true }, + }, + }, + }, + }); + + const connection = extractConnection( + result, + 'resendContacts', + ); + + for (const edge of connection.edges) { + const primaryEmail = edge.node.email?.primaryEmail; + + if (!isNonEmptyString(primaryEmail)) continue; + + contactByEmail.set(normalize(primaryEmail), { + id: edge.node.id, + personId: edge.node.personId ?? null, + }); + } + + return contactByEmail; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-twenty-ids-by-resend-id.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-twenty-ids-by-resend-id.ts new file mode 100644 index 0000000000..679754bda9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/find-twenty-ids-by-resend-id.ts @@ -0,0 +1,55 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from '@utils/is-defined'; + +import { extractConnection } from '@modules/resend/shared/utils/typed-client'; + +type ResendIdNode = { + id: string; + resendId?: string | null; +}; + +export const findTwentyIdsByResendId = async ( + client: CoreApiClient, + objectNamePlural: string, + resendIds: ReadonlyArray, +): Promise> => { + const map = new Map(); + + const filteredIds = Array.from( + new Set( + resendIds.filter( + (resendId): resendId is string => + typeof resendId === 'string' && resendId.length > 0, + ), + ), + ); + + if (filteredIds.length === 0) return map; + + const result = await client.query({ + [objectNamePlural]: { + __args: { + filter: { + resendId: { in: filteredIds }, + }, + first: filteredIds.length, + }, + edges: { + node: { + id: true, + resendId: true, + }, + }, + }, + }); + + const connection = extractConnection(result, objectNamePlural); + + for (const edge of connection.edges) { + if (isDefined(edge.node.resendId)) { + map.set(edge.node.resendId, edge.node.id); + } + } + + return map; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/for-each-page.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/for-each-page.ts new file mode 100644 index 0000000000..1ee2d6d41c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/for-each-page.ts @@ -0,0 +1,121 @@ +import { isDefined } from '@utils/is-defined'; + +import { RESEND_PAGE_SIZE } from '@modules/resend/constants/sync-config'; +import { withRateLimitRetry } from '@modules/resend/shared/utils/with-rate-limit-retry'; + +export type ResendListFunction = (paginationParameters: { + limit: number; + after?: string; +}) => Promise<{ + data: { data: T[]; has_more: boolean } | null; + error: unknown; +}>; + +export type ForEachPageOptions = { + startCursor?: string; + onCursorAdvance?: (cursor: string) => Promise; + deadlineAtMs?: number; +}; + +export type OnPageResult = { + ok: boolean; + stop?: boolean; + errors?: ReadonlyArray; +}; + +export type OnPageHandler = ( + items: T[], + pageNumber: number, +) => Promise; + +export type ForEachPageResult = { + completed: boolean; +}; + +export const forEachPage = async ( + listFunction: ResendListFunction, + onPage: OnPageHandler, + label = 'items', + options?: ForEachPageOptions, +): Promise => { + let cursor: string | undefined = options?.startCursor; + let pageNumber = 0; + let totalFetched = 0; + + while (true) { + const paginationParameters = { + limit: RESEND_PAGE_SIZE, + ...(isDefined(cursor) && cursor.length > 0 && { after: cursor }), + }; + const response = await withRateLimitRetry( + () => listFunction(paginationParameters), + { channel: label }, + ); + + if (isDefined(response.error)) { + throw new Error( + `Resend list[${label}] failed at cursor=${cursor ?? 'start'}: ${JSON.stringify(response.error)}`, + ); + } + + const page = response.data; + + if (!isDefined(page) || page.data.length === 0) { + return { completed: true }; + } + + pageNumber++; + totalFetched += page.data.length; + + console.log( + `[resend] fetched ${label} page ${pageNumber} (size=${page.data.length}, total=${totalFetched}, has_more=${page.has_more})`, + ); + + const handlerResult = await onPage(page.data, pageNumber); + const pageOk = handlerResult?.ok ?? true; + const shouldStop = handlerResult?.stop === true; + + if (!pageOk) { + const perItemErrors = handlerResult?.errors ?? []; + const detail = + perItemErrors.length > 0 + ? ` failures: ${perItemErrors.join(' | ')}` + : ''; + + console.warn( + `[resend] ${label} page ${pageNumber} had per-item failures at cursor=${cursor ?? 'start'}; advancing past the page.${detail}`, + ); + } + + const nextCursor = page.data[page.data.length - 1].id; + + if (isDefined(options?.onCursorAdvance)) { + await options.onCursorAdvance(nextCursor); + } + + if (shouldStop) { + return { completed: true }; + } + + if (!page.has_more) { + return { completed: true }; + } + + if (nextCursor === cursor) { + throw new Error(`Resend list[${label}] cursor stuck at ${nextCursor}`); + } + + if ( + isDefined(options?.deadlineAtMs) && + Date.now() >= options.deadlineAtMs + ) { + console.log( + `[resend] reached deadline for ${label} after page ${pageNumber}; stopping early (cursor will resume next tick)`, + ); + + return { completed: false }; + } + + cursor = nextCursor; + } +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/get-resend-client.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/get-resend-client.ts index 5543037eac..304cdb1e8a 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/get-resend-client.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/get-resend-client.ts @@ -1,5 +1,5 @@ import { Resend } from 'resend'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; export const getResendClient = (): Resend => { const apiKey = process.env.RESEND_API_KEY; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/map-last-event.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/map-last-event.ts index d175a52c0d..7728a4e550 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/map-last-event.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/map-last-event.ts @@ -1,4 +1,4 @@ -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; export type LastEvent = | 'SENT' diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/to-emails-field.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/to-emails-field.ts index 6860c84f6d..af05220325 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/to-emails-field.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/to-emails-field.ts @@ -1,14 +1,32 @@ -import type { EmailsField } from 'src/modules/resend/shared/types/emails-field'; +import type { EmailsField } from '@modules/resend/shared/types/emails-field'; + +const extractEmailAddress = (raw: string): string => { + const trimmed = raw.trim(); + + const closingIndex = trimmed.lastIndexOf('>'); + const openingIndex = trimmed.lastIndexOf('<', closingIndex); + + if (openingIndex !== -1 && closingIndex > openingIndex) { + return trimmed.slice(openingIndex + 1, closingIndex).trim().toLowerCase(); + } + + return trimmed.toLowerCase(); +}; export const toEmailsField = ( value: string | string[] | undefined | null, ): EmailsField => { if (Array.isArray(value)) { + const normalized = value.map(extractEmailAddress); + return { - primaryEmail: value[0] ?? '', - additionalEmails: value.length > 1 ? value.slice(1) : null, + primaryEmail: normalized[0] ?? '', + additionalEmails: normalized.length > 1 ? normalized.slice(1) : null, }; } - return { primaryEmail: value ?? '', additionalEmails: null }; + return { + primaryEmail: typeof value === 'string' ? extractEmailAddress(value) : '', + additionalEmails: null, + }; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/to-iso-string.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/to-iso-string.ts index 60552e3411..596c7aa669 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/to-iso-string.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/to-iso-string.ts @@ -1,4 +1,4 @@ -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; export const toIsoString = (date: string): string => new Date(date).toISOString(); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/typed-client.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/typed-client.ts new file mode 100644 index 0000000000..3cd967be1e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/typed-client.ts @@ -0,0 +1,47 @@ +export type PageInfo = { + hasNextPage: boolean; + endCursor: string | null; +}; + +export type Connection = { + edges: Array<{ node: TNode }>; + pageInfo?: PageInfo; +}; + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +export const extractConnection = ( + result: unknown, + field: string, +): Connection => { + if (!isObject(result)) { + return { edges: [] }; + } + + const candidate = result[field]; + + if (!isObject(candidate)) { + return { edges: [] }; + } + + return { + edges: Array.isArray((candidate as Connection).edges) + ? (candidate as Connection).edges + : [], + pageInfo: (candidate as Connection).pageInfo, + }; +}; + +export const extractMutationRecord = ( + result: unknown, + field: string, +): T | undefined => { + if (!isObject(result)) return undefined; + + const candidate = result[field]; + + if (!isObject(candidate)) return undefined; + + return candidate as T; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/with-rate-limit-retry.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/with-rate-limit-retry.ts index a84eb0587e..3f2133baa2 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/with-rate-limit-retry.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/shared/utils/with-rate-limit-retry.ts @@ -1,3 +1,11 @@ +import { isDefined } from '@utils/is-defined'; + +import { + RATE_LIMIT_BASE_DELAY_MS, + RATE_LIMIT_MAX_RETRIES, + RATE_LIMIT_MIN_INTERVAL_MS, +} from '@modules/resend/constants/sync-config'; + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -7,7 +15,9 @@ const isRateLimitError = (error: unknown): boolean => { ? error.message : typeof error === 'object' && error !== null ? JSON.stringify(error) - : ''; + : typeof error === 'string' + ? error + : ''; const lower = text.toLowerCase(); return ( @@ -17,31 +27,63 @@ const isRateLimitError = (error: unknown): boolean => { ); }; -const MAX_RETRIES = 5; -const BASE_DELAY_MS = 1000; -const MIN_INTERVAL_MS = 220; +const isResendErrorResponse = ( + value: unknown, +): value is { error: unknown; data?: unknown } => { + if (typeof value !== 'object' || value === null) return false; -let lastCallTimestamp = 0; + return ( + 'error' in value && + isDefined((value as { error: unknown }).error) + ); +}; + +const lastCallTimestampByChannel = new Map(); +const DEFAULT_CHANNEL = 'default'; export const withRateLimitRetry = async ( fn: () => Promise, + options?: { channel?: string }, ): Promise => { - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const channel = options?.channel ?? DEFAULT_CHANNEL; + + for (let attempt = 0; attempt <= RATE_LIMIT_MAX_RETRIES; attempt++) { + const lastCallTimestamp = lastCallTimestampByChannel.get(channel) ?? 0; const elapsed = Date.now() - lastCallTimestamp; - if (elapsed < MIN_INTERVAL_MS) await sleep(MIN_INTERVAL_MS - elapsed); + if (elapsed < RATE_LIMIT_MIN_INTERVAL_MS) { + await sleep(RATE_LIMIT_MIN_INTERVAL_MS - elapsed); + } - lastCallTimestamp = Date.now(); + lastCallTimestampByChannel.set(channel, Date.now()); try { - return await fn(); - } catch (error) { - if (!isRateLimitError(error) || attempt === MAX_RETRIES) throw error; + const result = await fn(); - const delayMs = BASE_DELAY_MS * 2 ** attempt; + if ( + isResendErrorResponse(result) && + isRateLimitError(result.error) && + attempt < RATE_LIMIT_MAX_RETRIES + ) { + const delayMs = RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt; + + console.warn( + `[resend] Rate limited (response.error), retrying in ${delayMs}ms (attempt ${attempt + 1}/${RATE_LIMIT_MAX_RETRIES})`, + ); + await sleep(delayMs); + continue; + } + + return result; + } catch (error) { + if (!isRateLimitError(error) || attempt === RATE_LIMIT_MAX_RETRIES) { + throw error; + } + + const delayMs = RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt; console.warn( - `[resend] Rate limited, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`, + `[resend] Rate limited (thrown), retrying in ${delayMs}ms (attempt ${attempt + 1}/${RATE_LIMIT_MAX_RETRIES})`, ); await sleep(delayMs); } diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/constants/resend-sync-cursor-steps.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/constants/resend-sync-cursor-steps.ts new file mode 100644 index 0000000000..c2b8e34ac3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/constants/resend-sync-cursor-steps.ts @@ -0,0 +1,10 @@ +import type { SyncCursorStep } from '@modules/resend/sync/cursor/types/sync-cursor-step'; + +export const RESEND_SYNC_CURSOR_STEPS: ReadonlyArray = [ + 'TOPICS', + 'SEGMENTS', + 'TEMPLATES', + 'CONTACTS', + 'EMAILS', + 'BROADCASTS', +]; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/types/sync-cursor-step.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/types/sync-cursor-step.ts new file mode 100644 index 0000000000..26f6a24ec9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/types/sync-cursor-step.ts @@ -0,0 +1,13 @@ +export type SyncCursorStep = + | 'SEGMENTS' + | 'TEMPLATES' + | 'CONTACTS' + | 'EMAILS' + | 'BROADCASTS' + | 'TOPICS'; + +export type SyncCursorRow = { + id: string; + step: SyncCursorStep; + cursor: string | null; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/__tests__/reset-all-sync-cursors.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/__tests__/reset-all-sync-cursors.test.ts new file mode 100644 index 0000000000..1b2f53bf1e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/__tests__/reset-all-sync-cursors.test.ts @@ -0,0 +1,88 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { resetAllSyncCursors } from '@modules/resend/sync/cursor/utils/reset-all-sync-cursors'; + +type CursorNode = { + id: string; + step?: string; + cursor?: string | null; + lastRunStatus?: 'SUCCESS' | 'FAILED' | 'IN_PROGRESS' | null; +}; + +const makeClient = (nodes: CursorNode[]) => { + const mutationCalls: Array> = []; + + const query = vi.fn(async () => ({ + resendSyncCursors: { + edges: nodes.map((node) => ({ node })), + }, + })); + + const mutation = vi.fn(async (m: Record) => { + mutationCalls.push(m); + + return { updateResendSyncCursor: { id: 'updated' } }; + }); + + const client = { query, mutation } as unknown as CoreApiClient; + + return { client, mutationCalls, query, mutation }; +}; + +const extractUpdateArgs = (call: Record) => { + const updateBlock = call.updateResendSyncCursor as + | { __args?: { id?: string; data?: Record } } + | undefined; + + return updateBlock?.__args; +}; + +describe('resetAllSyncCursors', () => { + it('issues no mutations when there are no rows', async () => { + const { client, mutation } = makeClient([]); + + await resetAllSyncCursors(client); + + expect(mutation).not.toHaveBeenCalled(); + }); + + it('resets every returned row with cursor/lastRunAt/lastRunStatus nulled', async () => { + const nodes: CursorNode[] = [ + { id: 'row-1', step: 'TOPICS', cursor: null, lastRunStatus: 'SUCCESS' }, + { id: 'row-2', step: 'EMAILS', cursor: 'mid', lastRunStatus: 'IN_PROGRESS' }, + { id: 'row-3', step: 'BROADCASTS', cursor: null, lastRunStatus: 'FAILED' }, + ]; + const { client, mutationCalls } = makeClient(nodes); + + await resetAllSyncCursors(client); + + expect(mutationCalls).toHaveLength(3); + + const updatedIds = mutationCalls + .map((call) => extractUpdateArgs(call)?.id) + .sort(); + + expect(updatedIds).toEqual(['row-1', 'row-2', 'row-3']); + + for (const call of mutationCalls) { + expect(extractUpdateArgs(call)?.data).toEqual({ + cursor: null, + lastRunAt: null, + lastRunStatus: null, + }); + } + }); + + it('skips edges whose node has no id', async () => { + const { client, mutationCalls } = makeClient([ + { id: 'row-1' }, + { id: '' }, + ]); + + await resetAllSyncCursors(client); + + expect(mutationCalls).toHaveLength(1); + expect(extractUpdateArgs(mutationCalls[0])?.id).toBe('row-1'); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/__tests__/with-sync-cursor.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/__tests__/with-sync-cursor.test.ts new file mode 100644 index 0000000000..84697e6e53 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/__tests__/with-sync-cursor.test.ts @@ -0,0 +1,168 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { withSyncCursor } from 'src/modules/resend/sync/cursor/utils/with-sync-cursor'; + +type CursorNode = { + id: string; + step: string; + cursor: string | null; +}; + +const makeClient = (existingNode?: CursorNode) => { + const mutationCalls: Array> = []; + const queryCalls: Array> = []; + + const query = vi.fn(async (q: Record) => { + queryCalls.push(q); + + return { + resendSyncCursors: { + edges: existingNode ? [{ node: existingNode }] : [], + }, + }; + }); + + const mutation = vi.fn(async (m: Record) => { + mutationCalls.push(m); + + if ('createResendSyncCursor' in m) { + return { createResendSyncCursor: { id: 'created-cursor-id' } }; + } + + return { updateResendSyncCursor: { id: 'updated' } }; + }); + + const client = { query, mutation } as unknown as CoreApiClient; + + return { client, mutationCalls, queryCalls }; +}; + +const findUpdate = ( + calls: Array>, + predicate: (data: Record) => boolean, +): Record | undefined => { + for (const call of calls) { + const updateBlock = call.updateResendSyncCursor as + | { __args?: { data?: Record } } + | undefined; + + const data = updateBlock?.__args?.data; + + if (data !== undefined && predicate(data)) { + return data; + } + } + + return undefined; +}; + +describe('withSyncCursor', () => { + it('creates a cursor row when none exists, then clears the cursor on success', async () => { + const { client, mutationCalls } = makeClient(); + + await withSyncCursor(client, 'SEGMENTS', async ({ resumeCursor }) => { + expect(resumeCursor).toBeUndefined(); + + return { value: undefined, completed: true }; + }); + + const create = mutationCalls.find((c) => 'createResendSyncCursor' in c); + + expect(create).toBeDefined(); + + const clearedUpdate = findUpdate( + mutationCalls, + (data) => data.cursor === null, + ); + + expect(clearedUpdate).toBeDefined(); + }); + + it('resumes from stored cursor when one is persisted', async () => { + const { client } = makeClient({ + id: 'cursor-1', + step: 'CONTACTS', + cursor: 'last-id', + }); + + const seen: { resumeCursor: string | undefined }[] = []; + + await withSyncCursor(client, 'CONTACTS', async (ctx) => { + seen.push({ resumeCursor: ctx.resumeCursor }); + + return { value: undefined, completed: true }; + }); + + expect(seen).toEqual([{ resumeCursor: 'last-id' }]); + }); + + it('persists progress via onCursorAdvance', async () => { + const { client, mutationCalls } = makeClient({ + id: 'cursor-1', + step: 'EMAILS', + cursor: null, + }); + + await withSyncCursor(client, 'EMAILS', async ({ onCursorAdvance }) => { + await onCursorAdvance('item-99'); + + return { value: undefined, completed: false }; + }); + + const progress = findUpdate( + mutationCalls, + (data) => data.cursor === 'item-99', + ); + + expect(progress).toBeDefined(); + }); + + it('preserves the resume cursor when preserveCursor=true and run completes', async () => { + const { client, mutationCalls } = makeClient({ + id: 'cursor-1', + step: 'EMAILS', + cursor: 'in-progress-id', + }); + + await withSyncCursor( + client, + 'EMAILS', + async ({ onCursorAdvance }) => { + await onCursorAdvance('item-99'); + + return { value: undefined, completed: true }; + }, + { preserveCursor: true }, + ); + + const cursorWriteAttempt = findUpdate( + mutationCalls, + (data) => + Object.prototype.hasOwnProperty.call(data, 'cursor'), + ); + + expect(cursorWriteAttempt).toBeUndefined(); + }); + + it('rethrows and leaves the cursor untouched when fn throws', async () => { + const { client, mutationCalls } = makeClient({ + id: 'cursor-1', + step: 'CONTACTS', + cursor: 'last-id', + }); + + await expect( + withSyncCursor(client, 'CONTACTS', async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + + const clearedUpdate = findUpdate( + mutationCalls, + (data) => data.cursor === null, + ); + + expect(clearedUpdate).toBeUndefined(); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/get-or-create-sync-cursor.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/get-or-create-sync-cursor.ts new file mode 100644 index 0000000000..5b5725df66 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/get-or-create-sync-cursor.ts @@ -0,0 +1,130 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from '@utils/is-defined'; + +import type { + SyncCursorRow, + SyncCursorStep, +} from 'src/modules/resend/sync/cursor/types/sync-cursor-step'; +import { + extractConnection, + extractMutationRecord, +} from '@modules/resend/shared/utils/typed-client'; + +type SyncCursorNode = { + id: string; + step: SyncCursorStep; + cursor: string | null; +}; + +const findExistingCursor = async ( + client: CoreApiClient, + step: SyncCursorStep, +): Promise => { + const queryResult = await client.query({ + resendSyncCursors: { + __args: { + filter: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + step: { eq: step as any }, + }, + first: 1, + }, + edges: { + node: { + id: true, + step: true, + cursor: true, + }, + }, + }, + }); + + const connection = extractConnection( + queryResult, + 'resendSyncCursors', + ); + + const existingNode = connection.edges[0]?.node; + + if (!isDefined(existingNode)) { + return null; + } + + return { + id: existingNode.id, + step: existingNode.step, + cursor: existingNode.cursor, + }; +}; + +export const getOrCreateSyncCursor = async ( + client: CoreApiClient, + step: SyncCursorStep, +): Promise => { + const existing = await findExistingCursor(client, step); + + if (isDefined(existing)) { + return existing; + } + + try { + const createResult = await client.mutation({ + createResendSyncCursor: { + __args: { + data: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + step: step as any, + }, + }, + id: true, + }, + }); + + const created = extractMutationRecord<{ id: string }>( + createResult, + 'createResendSyncCursor', + ); + + if (!isDefined(created)) { + throw new Error(`Failed to create resendSyncCursor for step ${step}`); + } + + return { + id: created.id, + step, + cursor: null, + }; + } catch (createError) { + if (!isUniqueViolationError(createError)) { + throw createError; + } + + const raceWinner = await findExistingCursor(client, step); + + if (isDefined(raceWinner)) { + return raceWinner; + } + + throw createError; + } +}; + +const isUniqueViolationError = (error: unknown): boolean => { + const text = + error instanceof Error + ? error.message + : typeof error === 'object' && error !== null + ? JSON.stringify(error) + : typeof error === 'string' + ? error + : ''; + const lower = text.toLowerCase(); + + return ( + lower.includes('duplicate') || + lower.includes('unique constraint') || + lower.includes('uniqueness') || + lower.includes('already exists') || + lower.includes('violates unique') + ); +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/reset-all-sync-cursors.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/reset-all-sync-cursors.ts new file mode 100644 index 0000000000..5710cbf998 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/reset-all-sync-cursors.ts @@ -0,0 +1,46 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from '@utils/is-defined'; + +import { extractConnection } from '@modules/resend/shared/utils/typed-client'; +import { RESEND_SYNC_CURSOR_STEPS } from '@modules/resend/sync/cursor/constants/resend-sync-cursor-steps'; +import { updateCursorRow } from '@modules/resend/sync/cursor/utils/set-cursor'; + +type SyncCursorIdNode = { + id: string; +}; + +export const resetAllSyncCursors = async ( + client: CoreApiClient, +): Promise => { + const queryResult = await client.query({ + resendSyncCursors: { + __args: { + first: RESEND_SYNC_CURSOR_STEPS.length + 5, + }, + edges: { + node: { + id: true, + }, + }, + }, + }); + + const connection = extractConnection( + queryResult, + 'resendSyncCursors', + ); + + const ids = connection.edges + .map((edge) => edge.node?.id) + .filter((id): id is string => isDefined(id) && id.length > 0); + + await Promise.all( + ids.map((id) => + updateCursorRow(client, id, { + cursor: null, + lastRunAt: null, + lastRunStatus: null, + }), + ), + ); +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/set-cursor.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/set-cursor.ts new file mode 100644 index 0000000000..b313803a42 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/set-cursor.ts @@ -0,0 +1,35 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; + +export type SyncRunStatus = 'SUCCESS' | 'FAILED' | 'IN_PROGRESS'; + +type CursorUpdate = { + cursor?: string | null; + lastRunAt?: string | null; + lastRunStatus?: SyncRunStatus | null; +}; + +export const setCursor = async ( + client: CoreApiClient, + id: string, + cursor: string | null, +): Promise => { + await client.mutation({ + updateResendSyncCursor: { + __args: { id, data: { cursor } }, + id: true, + }, + }); +}; + +export const updateCursorRow = async ( + client: CoreApiClient, + id: string, + data: CursorUpdate, +): Promise => { + await client.mutation({ + updateResendSyncCursor: { + __args: { id, data }, + id: true, + }, + }); +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/with-sync-cursor.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/with-sync-cursor.ts new file mode 100644 index 0000000000..c565bc4d2d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/cursor/utils/with-sync-cursor.ts @@ -0,0 +1,80 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from '@utils/is-defined'; + +import { getOrCreateSyncCursor } from 'src/modules/resend/sync/cursor/utils/get-or-create-sync-cursor'; +import { + setCursor, + updateCursorRow, +} from 'src/modules/resend/sync/cursor/utils/set-cursor'; +import type { SyncCursorStep } from 'src/modules/resend/sync/cursor/types/sync-cursor-step'; + +export type SyncCursorContext = { + resumeCursor: string | undefined; + onCursorAdvance: (cursor: string) => Promise; +}; + +export type SyncCursorRunResult = { + value: TValue; + completed: boolean; +}; + +export type WithSyncCursorOptions = { + preserveCursor?: boolean; +}; + +export const withSyncCursor = async ( + client: CoreApiClient, + step: SyncCursorStep, + runWithCursor: ( + context: SyncCursorContext, + ) => Promise>, + options?: WithSyncCursorOptions, +): Promise => { + const cursorRow = await getOrCreateSyncCursor(client, step); + const startedAt = new Date().toISOString(); + const preserveCursor = options?.preserveCursor === true; + + await updateCursorRow(client, cursorRow.id, { + lastRunAt: startedAt, + lastRunStatus: 'IN_PROGRESS', + }); + + const hasResumeCursor = + isDefined(cursorRow.cursor) && cursorRow.cursor.length > 0; + + if (hasResumeCursor) { + console.log( + `[sync] resuming step ${step} from cursor ${cursorRow.cursor}`, + ); + } + + const context: SyncCursorContext = { + resumeCursor: hasResumeCursor ? cursorRow.cursor ?? undefined : undefined, + onCursorAdvance: preserveCursor + ? async () => undefined + : (cursor) => setCursor(client, cursorRow.id, cursor), + }; + + try { + const { value, completed } = await runWithCursor(context); + + if (completed) { + await updateCursorRow(client, cursorRow.id, { + ...(preserveCursor ? {} : { cursor: null }), + lastRunStatus: 'SUCCESS', + }); + } else { + await updateCursorRow(client, cursorRow.id, { + lastRunStatus: 'IN_PROGRESS', + }); + } + + return value; + } catch (runError) { + await updateCursorRow(client, cursorRow.id, { + lastRunStatus: 'FAILED', + }); + + throw runError; + } +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-initial-sync-mode-monitor.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-initial-sync-mode-monitor.test.ts new file mode 100644 index 0000000000..7a412ab19c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-initial-sync-mode-monitor.test.ts @@ -0,0 +1,73 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resendInitialSyncModeMonitorHandler } from '@modules/resend/sync/logic-functions/resend-initial-sync-mode-monitor'; + +const mockSetInitialSyncMode = vi.fn(); +const mockAreAllSyncCursorsEmpty = vi.fn(); + +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: vi.fn(), +})); + +vi.mock('twenty-client-sdk/metadata', () => ({ + MetadataApiClient: vi.fn(), +})); + +vi.mock('@modules/resend/sync/utils/set-initial-sync-mode', () => ({ + setInitialSyncMode: (...args: unknown[]) => mockSetInitialSyncMode(...args), + isInitialSyncModeOn: async () => process.env.INITIAL_SYNC_MODE === 'true', +})); + +vi.mock('@modules/resend/sync/utils/are-all-sync-cursors-empty', () => ({ + areAllSyncCursorsEmpty: (...args: unknown[]) => + mockAreAllSyncCursorsEmpty(...args), +})); + +describe('resendInitialSyncModeMonitorHandler', () => { + const originalEnv = process.env.INITIAL_SYNC_MODE; + + beforeEach(() => { + mockSetInitialSyncMode.mockReset(); + mockAreAllSyncCursorsEmpty.mockReset(); + (CoreApiClient as unknown as ReturnType).mockReset(); + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.INITIAL_SYNC_MODE; + } else { + process.env.INITIAL_SYNC_MODE = originalEnv; + } + }); + + it('skips when INITIAL_SYNC_MODE is off', async () => { + process.env.INITIAL_SYNC_MODE = 'false'; + + const result = await resendInitialSyncModeMonitorHandler(); + + expect(result).toEqual({ skipped: true, flipped: false }); + expect(mockAreAllSyncCursorsEmpty).not.toHaveBeenCalled(); + expect(mockSetInitialSyncMode).not.toHaveBeenCalled(); + }); + + it('does nothing when cursors are not all empty', async () => { + process.env.INITIAL_SYNC_MODE = 'true'; + mockAreAllSyncCursorsEmpty.mockResolvedValue(false); + + const result = await resendInitialSyncModeMonitorHandler(); + + expect(result).toEqual({ skipped: false, flipped: false }); + expect(mockSetInitialSyncMode).not.toHaveBeenCalled(); + }); + + it('flips INITIAL_SYNC_MODE to false when all cursors are empty', async () => { + process.env.INITIAL_SYNC_MODE = 'true'; + mockAreAllSyncCursorsEmpty.mockResolvedValue(true); + + const result = await resendInitialSyncModeMonitorHandler(); + + expect(result).toEqual({ skipped: false, flipped: true }); + expect(mockSetInitialSyncMode).toHaveBeenCalledWith('false'); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-sync-broadcasts-and-dependencies.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-sync-broadcasts-and-dependencies.test.ts new file mode 100644 index 0000000000..1beb3abefd --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-sync-broadcasts-and-dependencies.test.ts @@ -0,0 +1,109 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resendSyncBroadcastsAndDependenciesHandler } from '@modules/resend/sync/logic-functions/resend-sync-broadcasts-and-dependencies'; + +const mockSyncTopics = vi.fn(); +const mockSyncSegments = vi.fn(); +const mockSyncBroadcasts = vi.fn(); +const mockGetResendClient = vi.fn(); + +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: vi.fn(), +})); + +vi.mock('@modules/resend/shared/utils/get-resend-client', () => ({ + getResendClient: () => mockGetResendClient(), +})); + +vi.mock('@modules/resend/sync/utils/sync-topics', () => ({ + syncTopics: (...args: unknown[]) => mockSyncTopics(...args), +})); + +vi.mock('@modules/resend/sync/utils/sync-segments', () => ({ + syncSegments: (...args: unknown[]) => mockSyncSegments(...args), +})); + +vi.mock('@modules/resend/sync/utils/sync-broadcasts', () => ({ + syncBroadcasts: (...args: unknown[]) => mockSyncBroadcasts(...args), +})); + +const okResult = (value: T) => ({ + result: { fetched: 0, created: 0, updated: 0, errors: [] }, + value, +}); + +describe('resendSyncBroadcastsAndDependenciesHandler', () => { + beforeEach(() => { + mockSyncTopics.mockReset(); + mockSyncSegments.mockReset(); + mockSyncBroadcasts.mockReset(); + mockGetResendClient.mockReset(); + (CoreApiClient as unknown as ReturnType).mockReset(); + + mockGetResendClient.mockReturnValue({}); + }); + + it('runs topics → segments → broadcasts in sequence with deadline propagation', async () => { + const topicMap = new Map([['topic-1', 'twenty-topic-1']]); + const segmentMap = new Map([['segment-1', 'twenty-segment-1']]); + + mockSyncTopics.mockResolvedValue(okResult(topicMap)); + mockSyncSegments.mockResolvedValue(okResult(segmentMap)); + mockSyncBroadcasts.mockResolvedValue(okResult(undefined)); + + const summary = await resendSyncBroadcastsAndDependenciesHandler(); + + expect(mockSyncTopics).toHaveBeenCalledTimes(1); + expect(mockSyncSegments).toHaveBeenCalledTimes(1); + expect(mockSyncBroadcasts).toHaveBeenCalledTimes(1); + + const broadcastsArgs = mockSyncBroadcasts.mock.calls[0]; + + expect(broadcastsArgs).toHaveLength(3); + expect(broadcastsArgs[2]).toEqual({ deadlineAtMs: expect.any(Number) }); + + const topicsArgs = mockSyncTopics.mock.calls[0]; + + expect(topicsArgs[3]).toEqual({ deadlineAtMs: expect.any(Number) }); + + const segmentsArgs = mockSyncSegments.mock.calls[0]; + + expect(segmentsArgs[3]).toEqual({ deadlineAtMs: expect.any(Number) }); + + expect(summary.steps.map((s) => s.name)).toEqual([ + 'TOPICS', + 'SEGMENTS', + 'BROADCASTS', + ]); + expect(summary.steps.map((s) => s.status)).toEqual(['ok', 'ok', 'ok']); + }); + + it('skips broadcasts when topics fails', async () => { + mockSyncTopics.mockRejectedValue(new Error('topics boom')); + mockSyncSegments.mockResolvedValue(okResult(new Map())); + + const summary = await resendSyncBroadcastsAndDependenciesHandler(); + + expect(mockSyncBroadcasts).not.toHaveBeenCalled(); + + const statusByName = new Map(summary.steps.map((s) => [s.name, s.status])); + + expect(statusByName.get('TOPICS')).toBe('failed'); + expect(statusByName.get('BROADCASTS')).toBe('skipped'); + }); + + it('skips broadcasts when segments fails', async () => { + mockSyncTopics.mockResolvedValue(okResult(new Map())); + mockSyncSegments.mockRejectedValue(new Error('segments boom')); + + const summary = await resendSyncBroadcastsAndDependenciesHandler(); + + expect(mockSyncBroadcasts).not.toHaveBeenCalled(); + + const statusByName = new Map(summary.steps.map((s) => [s.name, s.status])); + + expect(statusByName.get('SEGMENTS')).toBe('failed'); + expect(statusByName.get('BROADCASTS')).toBe('skipped'); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-sync-contacts.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-sync-contacts.test.ts new file mode 100644 index 0000000000..b55a383474 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-sync-contacts.test.ts @@ -0,0 +1,43 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resendSyncContactsHandler } from '@modules/resend/sync/logic-functions/resend-sync-contacts'; + +const mockSyncContacts = vi.fn(); + +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: vi.fn(), +})); + +vi.mock('@modules/resend/shared/utils/get-resend-client', () => ({ + getResendClient: () => ({}), +})); + +vi.mock('@modules/resend/sync/utils/sync-contacts', () => ({ + syncContacts: (...args: unknown[]) => mockSyncContacts(...args), +})); + +describe('resendSyncContactsHandler', () => { + beforeEach(() => { + mockSyncContacts.mockReset(); + (CoreApiClient as unknown as ReturnType).mockReset(); + }); + + it('invokes syncContacts with a deadline and reports an ok CONTACTS step', async () => { + mockSyncContacts.mockResolvedValue({ + result: { fetched: 1, created: 1, updated: 0, errors: [] }, + value: undefined, + }); + + const summary = await resendSyncContactsHandler(); + + expect(mockSyncContacts).toHaveBeenCalledTimes(1); + const args = mockSyncContacts.mock.calls[0]; + + expect(args[3]).toEqual({ deadlineAtMs: expect.any(Number) }); + expect(args[3].deadlineAtMs).toBeGreaterThan(Date.now()); + expect(summary.steps).toEqual([ + expect.objectContaining({ name: 'CONTACTS', status: 'ok', created: 1 }), + ]); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-sync-emails.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-sync-emails.test.ts new file mode 100644 index 0000000000..e54c650475 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-sync-emails.test.ts @@ -0,0 +1,74 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { INTERMEDIATE_SYNC_EMAILS_MAX_AGE_MS } from '@modules/resend/constants/sync-config'; +import { resendSyncEmailsHandler } from '@modules/resend/sync/logic-functions/resend-sync-emails'; + +const mockSyncEmails = vi.fn(); + +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: vi.fn(), +})); + +vi.mock('@modules/resend/shared/utils/get-resend-client', () => ({ + getResendClient: () => ({}), +})); + +vi.mock('@modules/resend/sync/utils/set-initial-sync-mode', () => ({ + isInitialSyncModeOn: async () => process.env.INITIAL_SYNC_MODE === 'true', + setInitialSyncMode: vi.fn(), +})); + +vi.mock('@modules/resend/sync/utils/sync-emails', () => ({ + syncEmails: (...args: unknown[]) => mockSyncEmails(...args), +})); + +describe('resendSyncEmailsHandler', () => { + const originalMode = process.env.INITIAL_SYNC_MODE; + + beforeEach(() => { + mockSyncEmails.mockReset(); + mockSyncEmails.mockResolvedValue({ + result: { fetched: 0, created: 0, updated: 0, errors: [] }, + value: undefined, + }); + (CoreApiClient as unknown as ReturnType).mockReset(); + }); + + afterEach(() => { + if (originalMode === undefined) { + delete process.env.INITIAL_SYNC_MODE; + } else { + process.env.INITIAL_SYNC_MODE = originalMode; + } + }); + + it('runs a full resumable sync in initial mode and forwards a deadline', async () => { + process.env.INITIAL_SYNC_MODE = 'true'; + + await resendSyncEmailsHandler(); + + expect(mockSyncEmails).toHaveBeenCalledTimes(1); + const args = mockSyncEmails.mock.calls[0]; + + expect(args).toHaveLength(4); + expect(args[3]).toEqual({ deadlineAtMs: expect.any(Number) }); + expect(args[3].deadlineAtMs).toBeGreaterThan(Date.now()); + }); + + it('runs a 7-day non-resumable sync in intermediate mode and forwards a deadline', async () => { + process.env.INITIAL_SYNC_MODE = 'false'; + + await resendSyncEmailsHandler(); + + expect(mockSyncEmails).toHaveBeenCalledTimes(1); + const args = mockSyncEmails.mock.calls[0]; + + expect(args[3]).toEqual({ + stopBeforeCreatedAtMs: INTERMEDIATE_SYNC_EMAILS_MAX_AGE_MS, + resumable: false, + deadlineAtMs: expect.any(Number), + }); + expect(args[3].deadlineAtMs).toBeGreaterThan(Date.now()); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-sync-templates.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-sync-templates.test.ts new file mode 100644 index 0000000000..d9a4ef47dd --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/__tests__/resend-sync-templates.test.ts @@ -0,0 +1,59 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resendSyncTemplatesHandler } from '@modules/resend/sync/logic-functions/resend-sync-templates'; + +const mockSyncTemplates = vi.fn(); + +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: vi.fn(), +})); + +vi.mock('@modules/resend/shared/utils/get-resend-client', () => ({ + getResendClient: () => ({}), +})); + +vi.mock('@modules/resend/sync/utils/sync-templates', () => ({ + syncTemplates: (...args: unknown[]) => mockSyncTemplates(...args), +})); + +describe('resendSyncTemplatesHandler', () => { + beforeEach(() => { + mockSyncTemplates.mockReset(); + (CoreApiClient as unknown as ReturnType).mockReset(); + }); + + it('reports an ok TEMPLATES step on success and forwards a deadline', async () => { + mockSyncTemplates.mockResolvedValue({ + result: { fetched: 5, created: 2, updated: 3, errors: [] }, + value: undefined, + }); + + const summary = await resendSyncTemplatesHandler(); + + expect(mockSyncTemplates).toHaveBeenCalledTimes(1); + const args = mockSyncTemplates.mock.calls[0]; + + expect(args[2]).toEqual({ deadlineAtMs: expect.any(Number) }); + expect(args[2].deadlineAtMs).toBeGreaterThan(Date.now()); + expect(summary.steps).toEqual([ + expect.objectContaining({ + name: 'TEMPLATES', + status: 'ok', + fetched: 5, + created: 2, + updated: 3, + }), + ]); + }); + + it('reports a failed TEMPLATES step when sync throws', async () => { + mockSyncTemplates.mockRejectedValue(new Error('boom')); + + const summary = await resendSyncTemplatesHandler(); + + expect(summary.steps).toEqual([ + expect.objectContaining({ name: 'TEMPLATES', status: 'failed' }), + ]); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-created.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-created.ts index 70ab17f70e..ee55dc6d09 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-created.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-created.ts @@ -1,12 +1,12 @@ import { isNonEmptyString } from '@sniptt/guards'; import { CoreApiClient } from 'twenty-client-sdk/core'; import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordCreateEvent } from 'twenty-sdk/define'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; -import { ON_RESEND_CONTACT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers'; -import type { ResendContactRecord } from 'src/modules/resend/shared/types/resend-contact-record'; -import { findOrCreatePerson } from 'src/modules/resend/shared/utils/find-or-create-person'; -import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client'; +import { ON_RESEND_CONTACT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; +import type { ResendContactRecord } from '@modules/resend/shared/types/resend-contact-record'; +import { findOrCreatePerson } from '@modules/resend/shared/utils/find-or-create-person'; +import { getResendClient } from '@modules/resend/shared/utils/get-resend-client'; type ContactCreateEvent = DatabaseEventPayload< ObjectRecordCreateEvent @@ -27,9 +27,9 @@ const handler = async ( return { skipped: true, reason: 'no email on record' }; } - const resend = getResendClient(); + const resendClient = getResendClient(); - const { data, error } = await resend.contacts.create({ + const { data, error } = await resendClient.contacts.create({ email, firstName: after.name?.firstName ?? undefined, lastName: after.name?.lastName ?? undefined, diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-deleted.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-destroyed.ts similarity index 54% rename from packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-deleted.ts rename to packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-destroyed.ts index cfb6b1100c..f46bb10412 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-deleted.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-destroyed.ts @@ -1,17 +1,17 @@ import { isNonEmptyString } from '@sniptt/guards'; -import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordDeleteEvent } from 'twenty-sdk/define'; -import { isDefined } from 'twenty-shared/utils'; +import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordDestroyEvent } from 'twenty-sdk/define'; +import { isDefined } from '@utils/is-defined'; -import { ON_RESEND_CONTACT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers'; -import type { ResendContactRecord } from 'src/modules/resend/shared/types/resend-contact-record'; -import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client'; +import { ON_RESEND_CONTACT_DESTROYED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; +import type { ResendContactRecord } from '@modules/resend/shared/types/resend-contact-record'; +import { getResendClient } from '@modules/resend/shared/utils/get-resend-client'; -type ContactDeleteEvent = DatabaseEventPayload< - ObjectRecordDeleteEvent +type ContactDestroyEvent = DatabaseEventPayload< + ObjectRecordDestroyEvent >; const handler = async ( - event: ContactDeleteEvent, + event: ContactDestroyEvent, ): Promise => { const resendId = event.properties.before?.resendId; @@ -35,17 +35,17 @@ const handler = async ( ); } - return { synced: true, resendId, action: 'deleted' }; + return { synced: true, resendId, action: 'destroyed' }; }; export default defineLogicFunction({ - universalIdentifier: ON_RESEND_CONTACT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, - name: 'on-resend-contact-deleted', + universalIdentifier: ON_RESEND_CONTACT_DESTROYED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'on-resend-contact-destroyed', description: - 'Removes a contact from Resend when a resendContact record is deleted in Twenty', + 'Removes a contact from Resend when a resendContact record is permanently destroyed in Twenty', timeoutSeconds: 30, handler, databaseEventTriggerSettings: { - eventName: 'resendContact.deleted', + eventName: 'resendContact.destroyed', }, }); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-updated.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-updated.ts index 79695e7340..b0efd26fc7 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-updated.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-contact-updated.ts @@ -1,45 +1,62 @@ import { isNonEmptyString } from '@sniptt/guards'; import { CoreApiClient } from 'twenty-client-sdk/core'; import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordUpdateEvent } from 'twenty-sdk/define'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; -import { ON_RESEND_CONTACT_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers'; -import type { ResendContactRecord } from 'src/modules/resend/shared/types/resend-contact-record'; -import { findOrCreatePerson } from 'src/modules/resend/shared/utils/find-or-create-person'; -import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client'; +import { ON_RESEND_CONTACT_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; +import type { ResendContactRecord } from '@modules/resend/shared/types/resend-contact-record'; +import { findOrCreatePerson } from '@modules/resend/shared/utils/find-or-create-person'; +import { getResendClient } from '@modules/resend/shared/utils/get-resend-client'; type ContactUpdateEvent = DatabaseEventPayload< ObjectRecordUpdateEvent >; +const valuesEqual = (a: unknown, b: unknown): boolean => + JSON.stringify(a ?? null) === JSON.stringify(b ?? null); + const handler = async ( event: ContactUpdateEvent, ): Promise => { - if (event.properties.updatedFields?.includes('lastSyncedFromResend')) { + const { before, after } = event.properties; + + const lastSyncedChanged = !valuesEqual( + before?.lastSyncedFromResend, + after?.lastSyncedFromResend, + ); + + const unsubscribedChanged = !valuesEqual( + before?.unsubscribed, + after?.unsubscribed, + ); + const nameChanged = !valuesEqual(before?.name, after?.name); + const emailChanged = !valuesEqual(before?.email, after?.email); + const userFieldsChanged = unsubscribedChanged || nameChanged || emailChanged; + + if (lastSyncedChanged && !userFieldsChanged) { return { skipped: true, reason: 'inbound sync echo' }; } - const { after } = event.properties; const resendId = after?.resendId; if (!isNonEmptyString(resendId)) { return { skipped: true, reason: 'no resendId on record' }; } - const resend = getResendClient(); + const resendClient = getResendClient(); const updatePayload: Record = { id: resendId }; - if (event.properties.updatedFields?.includes('unsubscribed')) { + if (unsubscribedChanged) { updatePayload.unsubscribed = after.unsubscribed; } - if (event.properties.updatedFields?.includes('name')) { + if (nameChanged) { updatePayload.firstName = after.name?.firstName ?? null; updatePayload.lastName = after.name?.lastName ?? null; } - if (event.properties.updatedFields?.includes('email')) { + if (emailChanged) { updatePayload.email = after.email?.primaryEmail; } @@ -47,8 +64,8 @@ const handler = async ( return { skipped: true, reason: 'no relevant fields changed' }; } - const { error } = await resend.contacts.update( - updatePayload as Parameters[0], + const { error } = await resendClient.contacts.update( + updatePayload as Parameters[0], ); if (isDefined(error)) { @@ -59,7 +76,7 @@ const handler = async ( let personId: string | undefined; - if (event.properties.updatedFields?.includes('email')) { + if (emailChanged) { const email = after.email?.primaryEmail; const client = new CoreApiClient(); @@ -81,7 +98,9 @@ const handler = async ( return { synced: true, resendId, - updatedFields: Object.keys(updatePayload).filter((k) => k !== 'id'), + updatedFields: Object.keys(updatePayload).filter( + (payloadKey) => payloadKey !== 'id', + ), personId, }; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-segment-created.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-segment-created.ts index e4766f0386..0c7cbb62b6 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-segment-created.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-segment-created.ts @@ -2,10 +2,10 @@ import { isNonEmptyString } from '@sniptt/guards'; import { CoreApiClient } from 'twenty-client-sdk/core'; import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordCreateEvent } from 'twenty-sdk/define'; -import { ON_RESEND_SEGMENT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers'; -import type { ResendSegmentRecord } from 'src/modules/resend/shared/types/resend-segment-record'; -import { findOrCreateResendSegment } from 'src/modules/resend/sync/utils/find-or-create-resend-segment'; -import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client'; +import { ON_RESEND_SEGMENT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; +import type { ResendSegmentRecord } from '@modules/resend/shared/types/resend-segment-record'; +import { findOrCreateResendSegment } from '@modules/resend/sync/utils/find-or-create-resend-segment'; +import { getResendClient } from '@modules/resend/shared/utils/get-resend-client'; type SegmentCreateEvent = DatabaseEventPayload< ObjectRecordCreateEvent @@ -26,10 +26,10 @@ const handler = async ( return { skipped: true, reason: 'no name on record' }; } - const resend = getResendClient(); + const resendClient = getResendClient(); const client = new CoreApiClient(); - const resendId = await findOrCreateResendSegment(resend, client, name); + const resendId = await findOrCreateResendSegment(resendClient, client, name); await client.mutation({ updateResendSegment: { diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-segment-deleted.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-segment-destroyed.ts similarity index 51% rename from packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-segment-deleted.ts rename to packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-segment-destroyed.ts index 7730b12b16..2e76516337 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-segment-deleted.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/on-resend-segment-destroyed.ts @@ -1,17 +1,17 @@ import { isNonEmptyString } from '@sniptt/guards'; -import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordDeleteEvent } from 'twenty-sdk/define'; -import { isDefined } from 'twenty-shared/utils'; +import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordDestroyEvent } from 'twenty-sdk/define'; +import { isDefined } from '@utils/is-defined'; -import { ON_RESEND_SEGMENT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers'; -import type { ResendSegmentRecord } from 'src/modules/resend/shared/types/resend-segment-record'; -import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client'; +import { ON_RESEND_SEGMENT_DESTROYED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; +import type { ResendSegmentRecord } from '@modules/resend/shared/types/resend-segment-record'; +import { getResendClient } from '@modules/resend/shared/utils/get-resend-client'; -type SegmentDeleteEvent = DatabaseEventPayload< - ObjectRecordDeleteEvent +type SegmentDestroyEvent = DatabaseEventPayload< + ObjectRecordDestroyEvent >; const handler = async ( - event: SegmentDeleteEvent, + event: SegmentDestroyEvent, ): Promise => { const resendId = event.properties.before?.resendId; @@ -19,9 +19,9 @@ const handler = async ( return { skipped: true, reason: 'no resendId on record' }; } - const resend = getResendClient(); + const resendClient = getResendClient(); - const { error } = await resend.segments.remove(resendId); + const { error } = await resendClient.segments.remove(resendId); if (isDefined(error)) { const errorString = JSON.stringify(error); @@ -35,17 +35,17 @@ const handler = async ( ); } - return { synced: true, resendId, action: 'deleted' }; + return { synced: true, resendId, action: 'destroyed' }; }; export default defineLogicFunction({ - universalIdentifier: ON_RESEND_SEGMENT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, - name: 'on-resend-segment-deleted', + universalIdentifier: ON_RESEND_SEGMENT_DESTROYED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'on-resend-segment-destroyed', description: - 'Removes a segment from Resend when a resendSegment record is deleted in Twenty', + 'Removes a segment from Resend when a resendSegment record is permanently destroyed in Twenty', timeoutSeconds: 30, handler, databaseEventTriggerSettings: { - eventName: 'resendSegment.deleted', + eventName: 'resendSegment.destroyed', }, }); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-initial-sync-mode-monitor.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-initial-sync-mode-monitor.ts new file mode 100644 index 0000000000..125de49559 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-initial-sync-mode-monitor.ts @@ -0,0 +1,58 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { RESEND_INITIAL_SYNC_MODE_MONITOR_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; +import { areAllSyncCursorsEmpty } from '@modules/resend/sync/utils/are-all-sync-cursors-empty'; +import { + isInitialSyncModeOn, + setInitialSyncMode, +} from '@modules/resend/sync/utils/set-initial-sync-mode'; + +type ResendInitialSyncModeMonitorSummary = { + skipped: boolean; + flipped: boolean; +}; + +export const resendInitialSyncModeMonitorHandler = + async (): Promise => { + if (!(await isInitialSyncModeOn())) { + console.log( + '[resend-initial-sync-mode-monitor] INITIAL_SYNC_MODE is off - nothing to do', + ); + + return { skipped: true, flipped: false }; + } + + const coreApiClient = new CoreApiClient(); + + const allCursorsCleared = await areAllSyncCursorsEmpty(coreApiClient); + + if (!allCursorsCleared) { + console.log( + '[resend-initial-sync-mode-monitor] some sync cursors are still in progress; not flipping yet', + ); + + return { skipped: false, flipped: false }; + } + + await setInitialSyncMode('false'); + + console.log( + '[resend-initial-sync-mode-monitor] all sync cursors are empty - INITIAL_SYNC_MODE flipped to false', + ); + + return { skipped: false, flipped: true }; + }; + +export default defineLogicFunction({ + universalIdentifier: + RESEND_INITIAL_SYNC_MODE_MONITOR_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'resend-initial-sync-mode-monitor', + description: + 'When INITIAL_SYNC_MODE is on, watches the Resend sync cursors and flips it to false once every step has cleared its cursor.', + timeoutSeconds: 30, + handler: resendInitialSyncModeMonitorHandler, + cronTriggerSettings: { + pattern: '*/5 * * * *', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-sync-broadcasts-and-dependencies.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-sync-broadcasts-and-dependencies.ts new file mode 100644 index 0000000000..40737e668c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-sync-broadcasts-and-dependencies.ts @@ -0,0 +1,82 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { + RESEND_SYNC_CRON_PATTERNS, + RESEND_SYNC_SLOT_DEADLINE_SLACK_MS, + RESEND_SYNC_SLOT_TIMEOUT_SECONDS, +} from '@modules/resend/constants/sync-config'; +import { RESEND_SYNC_BROADCASTS_AND_DEPENDENCIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; +import { getResendClient } from '@modules/resend/shared/utils/get-resend-client'; +import type { StepOutcome } from '@modules/resend/sync/types/step-outcome'; +import { logStepOutcome } from '@modules/resend/sync/utils/log-step-outcome'; +import { + runSyncStep, + skipDueToFailedDependencies, +} from '@modules/resend/sync/utils/run-sync-step'; +import { + summariseOutcomes, + type SyncSummaryStep, +} from '@modules/resend/sync/utils/summarise-outcomes'; +import { syncBroadcasts } from '@modules/resend/sync/utils/sync-broadcasts'; +import { syncSegments } from '@modules/resend/sync/utils/sync-segments'; +import { syncTopics } from '@modules/resend/sync/utils/sync-topics'; + +type ResendSyncBroadcastsAndDependenciesSummary = { + totalDurationMs: number; + steps: SyncSummaryStep[]; +}; + +export const resendSyncBroadcastsAndDependenciesHandler = + async (): Promise => { + const resendClient = getResendClient(); + const coreApiClient = new CoreApiClient(); + const syncedAt = new Date().toISOString(); + + const deadlineAtMs = + Date.now() + + RESEND_SYNC_SLOT_TIMEOUT_SECONDS.BROADCASTS * 1_000 - + RESEND_SYNC_SLOT_DEADLINE_SLACK_MS; + + const topics = await runSyncStep('TOPICS', () => + syncTopics(resendClient, coreApiClient, syncedAt, { deadlineAtMs }), + ); + + const segments = await runSyncStep('SEGMENTS', () => + syncSegments(resendClient, coreApiClient, syncedAt, { deadlineAtMs }), + ); + + const broadcasts = + topics.status === 'ok' && segments.status === 'ok' + ? await runSyncStep('BROADCASTS', () => + syncBroadcasts(resendClient, coreApiClient, { deadlineAtMs }), + ) + : skipDueToFailedDependencies('BROADCASTS', { topics, segments }); + + const outcomes: ReadonlyArray> = [ + topics, + segments, + broadcasts, + ]; + + for (const outcome of outcomes) { + logStepOutcome(outcome); + } + + const { totalDurationMs, steps } = summariseOutcomes(outcomes); + + return { totalDurationMs, steps }; + }; + +export default defineLogicFunction({ + universalIdentifier: + RESEND_SYNC_BROADCASTS_AND_DEPENDENCIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'resend-sync-broadcasts-and-dependencies', + description: + 'Syncs Resend topics, segments, and broadcasts in sequence. Broadcasts depend on the in-memory topic and segment id maps produced by the first two steps. Each step has its own cursor and resumes from the last advance on the next tick if the function timeouts mid-pagination.', + timeoutSeconds: RESEND_SYNC_SLOT_TIMEOUT_SECONDS.BROADCASTS, + handler: resendSyncBroadcastsAndDependenciesHandler, + cronTriggerSettings: { + pattern: RESEND_SYNC_CRON_PATTERNS.BROADCASTS, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-sync-contacts.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-sync-contacts.ts new file mode 100644 index 0000000000..30ccaea637 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-sync-contacts.ts @@ -0,0 +1,56 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { + RESEND_SYNC_CRON_PATTERNS, + RESEND_SYNC_SLOT_DEADLINE_SLACK_MS, + RESEND_SYNC_SLOT_TIMEOUT_SECONDS, +} from '@modules/resend/constants/sync-config'; +import { RESEND_SYNC_CONTACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; +import { getResendClient } from '@modules/resend/shared/utils/get-resend-client'; +import { logStepOutcome } from '@modules/resend/sync/utils/log-step-outcome'; +import { runSyncStep } from '@modules/resend/sync/utils/run-sync-step'; +import { + summariseOutcomes, + type SyncSummaryStep, +} from '@modules/resend/sync/utils/summarise-outcomes'; +import { syncContacts } from '@modules/resend/sync/utils/sync-contacts'; + +type ResendSyncContactsSummary = { + totalDurationMs: number; + steps: SyncSummaryStep[]; +}; + +export const resendSyncContactsHandler = + async (): Promise => { + const resendClient = getResendClient(); + const coreApiClient = new CoreApiClient(); + const syncedAt = new Date().toISOString(); + + const deadlineAtMs = + Date.now() + + RESEND_SYNC_SLOT_TIMEOUT_SECONDS.CONTACTS * 1_000 - + RESEND_SYNC_SLOT_DEADLINE_SLACK_MS; + + const contacts = await runSyncStep('CONTACTS', () => + syncContacts(resendClient, coreApiClient, syncedAt, { deadlineAtMs }), + ); + + logStepOutcome(contacts); + + const { totalDurationMs, steps } = summariseOutcomes([contacts]); + + return { totalDurationMs, steps }; + }; + +export default defineLogicFunction({ + universalIdentifier: RESEND_SYNC_CONTACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'resend-sync-contacts', + description: + 'Syncs Resend contacts and links them to existing people by email. Resumes from its own cursor if the function timeouts mid-pagination.', + timeoutSeconds: RESEND_SYNC_SLOT_TIMEOUT_SECONDS.CONTACTS, + handler: resendSyncContactsHandler, + cronTriggerSettings: { + pattern: RESEND_SYNC_CRON_PATTERNS.CONTACTS, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-sync-emails.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-sync-emails.ts new file mode 100644 index 0000000000..f2a1a4247e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-sync-emails.ts @@ -0,0 +1,66 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { + INTERMEDIATE_SYNC_EMAILS_MAX_AGE_MS, + RESEND_SYNC_CRON_PATTERNS, + RESEND_SYNC_SLOT_DEADLINE_SLACK_MS, + RESEND_SYNC_SLOT_TIMEOUT_SECONDS, +} from '@modules/resend/constants/sync-config'; +import { RESEND_SYNC_EMAILS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; +import { getResendClient } from '@modules/resend/shared/utils/get-resend-client'; +import { logStepOutcome } from '@modules/resend/sync/utils/log-step-outcome'; +import { runSyncStep } from '@modules/resend/sync/utils/run-sync-step'; +import { isInitialSyncModeOn } from '@modules/resend/sync/utils/set-initial-sync-mode'; +import { + summariseOutcomes, + type SyncSummaryStep, +} from '@modules/resend/sync/utils/summarise-outcomes'; +import { syncEmails } from '@modules/resend/sync/utils/sync-emails'; + +type ResendSyncEmailsSummary = { + totalDurationMs: number; + steps: SyncSummaryStep[]; +}; + +export const resendSyncEmailsHandler = + async (): Promise => { + const resendClient = getResendClient(); + const coreApiClient = new CoreApiClient(); + const syncedAt = new Date().toISOString(); + + const initialMode = await isInitialSyncModeOn(); + + const deadlineAtMs = + Date.now() + + RESEND_SYNC_SLOT_TIMEOUT_SECONDS.EMAILS * 1_000 - + RESEND_SYNC_SLOT_DEADLINE_SLACK_MS; + + const emails = await runSyncStep('EMAILS', () => + initialMode + ? syncEmails(resendClient, coreApiClient, syncedAt, { deadlineAtMs }) + : syncEmails(resendClient, coreApiClient, syncedAt, { + stopBeforeCreatedAtMs: INTERMEDIATE_SYNC_EMAILS_MAX_AGE_MS, + resumable: false, + deadlineAtMs, + }), + ); + + logStepOutcome(emails); + + const { totalDurationMs, steps } = summariseOutcomes([emails]); + + return { totalDurationMs, steps }; + }; + +export default defineLogicFunction({ + universalIdentifier: RESEND_SYNC_EMAILS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'resend-sync-emails', + description: + 'Syncs Resend emails and links them to existing people by email. In initial sync mode it does a full resumable pass; in intermediate mode it only fetches emails created in the last 7 days and does not persist a cursor.', + timeoutSeconds: RESEND_SYNC_SLOT_TIMEOUT_SECONDS.EMAILS, + handler: resendSyncEmailsHandler, + cronTriggerSettings: { + pattern: RESEND_SYNC_CRON_PATTERNS.EMAILS, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-sync-templates.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-sync-templates.ts new file mode 100644 index 0000000000..46eb6f2ef0 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/resend-sync-templates.ts @@ -0,0 +1,55 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { + RESEND_SYNC_CRON_PATTERNS, + RESEND_SYNC_SLOT_DEADLINE_SLACK_MS, + RESEND_SYNC_SLOT_TIMEOUT_SECONDS, +} from '@modules/resend/constants/sync-config'; +import { RESEND_SYNC_TEMPLATES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; +import { getResendClient } from '@modules/resend/shared/utils/get-resend-client'; +import { logStepOutcome } from '@modules/resend/sync/utils/log-step-outcome'; +import { runSyncStep } from '@modules/resend/sync/utils/run-sync-step'; +import { + summariseOutcomes, + type SyncSummaryStep, +} from '@modules/resend/sync/utils/summarise-outcomes'; +import { syncTemplates } from '@modules/resend/sync/utils/sync-templates'; + +type ResendSyncTemplatesSummary = { + totalDurationMs: number; + steps: SyncSummaryStep[]; +}; + +export const resendSyncTemplatesHandler = + async (): Promise => { + const resendClient = getResendClient(); + const coreApiClient = new CoreApiClient(); + + const deadlineAtMs = + Date.now() + + RESEND_SYNC_SLOT_TIMEOUT_SECONDS.TEMPLATES * 1_000 - + RESEND_SYNC_SLOT_DEADLINE_SLACK_MS; + + const templates = await runSyncStep('TEMPLATES', () => + syncTemplates(resendClient, coreApiClient, { deadlineAtMs }), + ); + + logStepOutcome(templates); + + const { totalDurationMs, steps } = summariseOutcomes([templates]); + + return { totalDurationMs, steps }; + }; + +export default defineLogicFunction({ + universalIdentifier: RESEND_SYNC_TEMPLATES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'resend-sync-templates', + description: + 'Syncs Resend templates. Resumes from its own cursor if the function timeouts mid-pagination.', + timeoutSeconds: RESEND_SYNC_SLOT_TIMEOUT_SECONDS.TEMPLATES, + handler: resendSyncTemplatesHandler, + cronTriggerSettings: { + pattern: RESEND_SYNC_CRON_PATTERNS.TEMPLATES, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/sync-resend-data.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/sync-resend-data.test.ts deleted file mode 100644 index ca48e9c7e0..0000000000 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/sync-resend-data.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import type { StepOutcome } from 'src/modules/resend/sync/types/step-outcome'; -import type { SyncResult } from 'src/modules/resend/sync/types/sync-result'; -import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result'; -import { orchestrateSyncResend } from 'src/modules/resend/sync/utils/orchestrate-sync-resend'; -import { - MAX_ERRORS_IN_THROWN_MESSAGE, - reportAndThrowIfErrors, -} from 'src/modules/resend/sync/utils/report-and-throw-if-errors'; -import type { SegmentIdMap } from 'src/modules/resend/sync/utils/sync-segments'; - -const emptyResult = (): SyncResult => ({ - fetched: 0, - created: 0, - updated: 0, - errors: [], -}); - -const emptySegmentMap: SegmentIdMap = new Map(); - -const okSegments = (): Promise> => - Promise.resolve({ result: emptyResult(), value: emptySegmentMap }); - -const okTemplates = (): Promise => - Promise.resolve({ result: emptyResult(), value: undefined }); - -const okStep = (): Promise => - Promise.resolve({ result: emptyResult(), value: undefined }); - -describe('orchestrateSyncResend', () => { - it('runs segments, templates, contacts and emails concurrently', async () => { - const order: string[] = []; - - const trackStart = - (name: string, value: T) => - (): Promise> => { - order.push(`${name}:start`); - - return new Promise((resolve) => { - setImmediate(() => { - order.push(`${name}:end`); - resolve({ result: emptyResult(), value }); - }); - }); - }; - - await orchestrateSyncResend({ - syncSegments: trackStart('segments', emptySegmentMap), - syncTemplates: trackStart('templates', undefined), - syncContacts: trackStart('contacts', undefined), - syncEmails: trackStart('emails', undefined), - syncBroadcasts: () => okStep(), - }); - - expect(order.slice(0, 4)).toEqual([ - 'segments:start', - 'templates:start', - 'contacts:start', - 'emails:start', - ]); - }); - - it('runs broadcasts after segments resolved', async () => { - const broadcastsArgs: SegmentIdMap[] = []; - const segmentMap: SegmentIdMap = new Map([['seg-1', 'twenty-seg-1']]); - - const outcomes = await orchestrateSyncResend({ - syncSegments: () => - Promise.resolve({ result: emptyResult(), value: segmentMap }), - syncTemplates: okTemplates, - syncContacts: () => okStep(), - syncEmails: () => okStep(), - syncBroadcasts: (s) => { - broadcastsArgs.push(s); - - return okStep(); - }, - }); - - expect(broadcastsArgs).toHaveLength(1); - expect(broadcastsArgs[0]).toBe(segmentMap); - - const broadcasts = outcomes.find( - (outcome) => outcome.name === 'broadcasts', - ); - - expect(broadcasts?.status).toBe('ok'); - }); - - it('runs broadcasts when templates fails but segments succeeds', async () => { - const syncBroadcasts = vi.fn(() => okStep()); - - const outcomes = await orchestrateSyncResend({ - syncSegments: okSegments, - syncTemplates: () => Promise.reject(new Error('templates boom')), - syncContacts: okStep, - syncEmails: okStep, - syncBroadcasts, - }); - - expect(syncBroadcasts).toHaveBeenCalledTimes(1); - - const broadcasts = outcomes.find( - (outcome) => outcome.name === 'broadcasts', - ); - - expect(broadcasts?.status).toBe('ok'); - }); - - it('skips broadcasts with structured reason when segments fails', async () => { - const syncBroadcasts = vi.fn(() => okStep()); - - const outcomes = await orchestrateSyncResend({ - syncSegments: () => Promise.reject(new Error('segments boom')), - syncTemplates: okTemplates, - syncContacts: okStep, - syncEmails: okStep, - syncBroadcasts, - }); - - expect(syncBroadcasts).not.toHaveBeenCalled(); - - const broadcasts = outcomes.find( - (outcome) => outcome.name === 'broadcasts', - ); - - expect(broadcasts?.status).toBe('skipped'); - if (broadcasts?.status !== 'skipped') { - throw new Error('expected skipped outcome'); - } - expect(broadcasts.reason).toContain('segments'); - }); -}); - -describe('reportAndThrowIfErrors', () => { - it('does nothing when no step has errors', () => { - const outcomes: ReadonlyArray> = [ - { - name: 'segments', - status: 'ok', - durationMs: 1, - result: emptyResult(), - value: undefined, - }, - ]; - - expect(() => reportAndThrowIfErrors(outcomes)).not.toThrow(); - }); - - it('surfaces non-throwing per-item errors collected in result.errors', () => { - const outcomes: ReadonlyArray> = [ - { - name: 'contacts', - status: 'ok', - durationMs: 1, - result: { - fetched: 1, - created: 0, - updated: 0, - errors: ['contact 123: boom'], - }, - value: undefined, - }, - ]; - - expect(() => reportAndThrowIfErrors(outcomes)).toThrowError( - /Sync completed with 1 error/, - ); - expect(() => reportAndThrowIfErrors(outcomes)).toThrowError( - /\[contacts\] contact 123: boom/, - ); - }); - - it('surfaces step-level failures', () => { - const outcomes: ReadonlyArray> = [ - { - name: 'segments', - status: 'failed', - durationMs: 5, - error: 'top level boom', - }, - ]; - - expect(() => reportAndThrowIfErrors(outcomes)).toThrowError( - /\[segments\] top level boom/, - ); - }); - - it('truncates the thrown message after MAX_ERRORS_IN_THROWN_MESSAGE entries', () => { - const tooMany = MAX_ERRORS_IN_THROWN_MESSAGE + 7; - const outcomes: ReadonlyArray> = [ - { - name: 'emails', - status: 'ok', - durationMs: 1, - result: { - fetched: tooMany, - created: 0, - updated: 0, - errors: Array.from({ length: tooMany }, (_, i) => `err-${i}`), - }, - value: undefined, - }, - ]; - - let caught: Error | undefined; - try { - reportAndThrowIfErrors(outcomes); - } catch (error) { - caught = error as Error; - } - - expect(caught).toBeInstanceOf(Error); - expect(caught?.message).toContain(`Sync completed with ${tooMany} error`); - expect(caught?.message).toContain('...and 7 more'); - - const renderedErrorLines = caught?.message - .split('\n') - .filter((line) => line.startsWith(' - ')) ?? []; - - expect(renderedErrorLines).toHaveLength(MAX_ERRORS_IN_THROWN_MESSAGE); - }); -}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/sync-resend-data.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/sync-resend-data.ts deleted file mode 100644 index 2cc287091a..0000000000 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/logic-functions/sync-resend-data.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { CoreApiClient } from 'twenty-client-sdk/core'; -import { defineLogicFunction } from 'twenty-sdk/define'; - -import { SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers'; -import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client'; -import { logStepOutcome } from 'src/modules/resend/sync/utils/log-step-outcome'; -import { orchestrateSyncResend } from 'src/modules/resend/sync/utils/orchestrate-sync-resend'; -import { reportAndThrowIfErrors } from 'src/modules/resend/sync/utils/report-and-throw-if-errors'; -import { syncBroadcasts } from 'src/modules/resend/sync/utils/sync-broadcasts'; -import { syncContacts } from 'src/modules/resend/sync/utils/sync-contacts'; -import { syncEmails } from 'src/modules/resend/sync/utils/sync-emails'; -import { syncSegments } from 'src/modules/resend/sync/utils/sync-segments'; -import { syncTemplates } from 'src/modules/resend/sync/utils/sync-templates'; - -const handler = async (): Promise => { - const resend = getResendClient(); - const client = new CoreApiClient(); - const syncedAt = new Date().toISOString(); - - const outcomes = await orchestrateSyncResend({ - syncSegments: () => syncSegments(resend, client, syncedAt), - syncTemplates: () => syncTemplates(resend, client), - syncContacts: () => syncContacts(resend, client, syncedAt), - syncEmails: () => syncEmails(resend, client, syncedAt), - syncBroadcasts: (segmentMap) => syncBroadcasts(resend, client, segmentMap), - }); - - for (const outcome of outcomes) { - logStepOutcome(outcome); - } - - reportAndThrowIfErrors(outcomes); -}; - -export default defineLogicFunction({ - universalIdentifier: SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, - name: 'sync-resend-data', - description: - 'Syncs emails, contacts, templates, broadcasts, and segments from Resend every 5 minutes', - timeoutSeconds: 300, - handler, - cronTriggerSettings: { - pattern: '*/5 * * * *', - }, -}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/contact.dto.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/contact.dto.ts index 7c8f06e2d2..7beff1abc5 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/contact.dto.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/contact.dto.ts @@ -1,4 +1,4 @@ -import type { EmailsField } from 'src/modules/resend/shared/types/emails-field'; +import type { EmailsField } from '@modules/resend/shared/types/emails-field'; export type ContactDto = { email: EmailsField; @@ -6,4 +6,6 @@ export type ContactDto = { unsubscribed: boolean; createdAt: string; lastSyncedFromResend: string; + personId?: string; + segmentId?: string; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/create-broadcast.dto.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/create-broadcast.dto.ts index d3b73ee94b..ff513c52dd 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/create-broadcast.dto.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/create-broadcast.dto.ts @@ -1,11 +1,6 @@ -import type { EmailsField } from 'src/modules/resend/shared/types/emails-field'; -import type { UpdateBroadcastDto } from 'src/modules/resend/sync/types/update-broadcast.dto'; +import type { UpdateBroadcastDto } from '@modules/resend/sync/types/update-broadcast.dto'; export type CreateBroadcastDto = UpdateBroadcastDto & { name: string; - subject: string | null; - fromAddress: EmailsField; - replyTo: EmailsField; - previewText: string; createdAt: string; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/create-email.dto.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/create-email.dto.ts index befe1d2dcf..4c479d43d6 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/create-email.dto.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/create-email.dto.ts @@ -1,8 +1,5 @@ -import type { UpdateEmailDto } from 'src/modules/resend/sync/types/update-email.dto'; +import type { UpdateEmailDto } from '@modules/resend/sync/types/update-email.dto'; export type CreateEmailDto = UpdateEmailDto & { - htmlBody: string; - textBody: string; createdAt: string; - tags: Array<{ name: string; value: string }> | undefined; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/create-template.dto.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/create-template.dto.ts index 8d92acf170..41b8940347 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/create-template.dto.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/create-template.dto.ts @@ -1,4 +1,4 @@ -import type { UpdateTemplateDto } from 'src/modules/resend/sync/types/update-template.dto'; +import type { UpdateTemplateDto } from '@modules/resend/sync/types/update-template.dto'; export type CreateTemplateDto = UpdateTemplateDto & { createdAt: string; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/step-outcome.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/step-outcome.ts index 79ee25481a..e26b1ec23a 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/step-outcome.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/step-outcome.ts @@ -1,4 +1,4 @@ -import type { SyncResult } from 'src/modules/resend/sync/types/sync-result'; +import type { SyncResult } from '@modules/resend/sync/types/sync-result'; export type StepOutcome = | { diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/sync-step-result.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/sync-step-result.ts index 7664fc5cc3..b03f3a4fe1 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/sync-step-result.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/sync-step-result.ts @@ -1,4 +1,4 @@ -import type { SyncResult } from 'src/modules/resend/sync/types/sync-result'; +import type { SyncResult } from '@modules/resend/sync/types/sync-result'; export type SyncStepResult = { result: SyncResult; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/topic.dto.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/topic.dto.ts new file mode 100644 index 0000000000..343f918e6d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/topic.dto.ts @@ -0,0 +1,8 @@ +export type TopicDto = { + name: string; + description: string; + defaultSubscription: string; + visibility: string; + createdAt: string; + lastSyncedFromResend: string; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/update-broadcast.dto.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/update-broadcast.dto.ts index f2ad7733bf..1d45489701 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/update-broadcast.dto.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/update-broadcast.dto.ts @@ -1,6 +1,15 @@ +import type { EmailsField } from '@modules/resend/shared/types/emails-field'; + export type UpdateBroadcastDto = { status: string; scheduledAt: string | null; sentAt: string | null; segmentId?: string | null; + topicId?: string | null; + subject?: string; + fromAddress?: EmailsField; + replyTo?: EmailsField; + previewText?: string; + htmlBody?: string; + textBody?: string; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/update-email.dto.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/update-email.dto.ts index f35d361d33..01407371c2 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/update-email.dto.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/update-email.dto.ts @@ -1,5 +1,5 @@ -import type { EmailsField } from 'src/modules/resend/shared/types/emails-field'; -import type { LastEvent } from 'src/modules/resend/shared/utils/map-last-event'; +import type { EmailsField } from '@modules/resend/shared/types/emails-field'; +import type { LastEvent } from '@modules/resend/shared/utils/map-last-event'; export type UpdateEmailDto = { subject: string; @@ -11,4 +11,7 @@ export type UpdateEmailDto = { lastEvent?: LastEvent; scheduledAt: string | null; lastSyncedFromResend: string; + personId?: string; + contactId?: string; + broadcastId?: string; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/update-template.dto.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/update-template.dto.ts index d7e0f5d3f8..90bdf12272 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/update-template.dto.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/update-template.dto.ts @@ -1,14 +1,14 @@ -import type { EmailsField } from 'src/modules/resend/shared/types/emails-field'; +import type { EmailsField } from '@modules/resend/shared/types/emails-field'; export type UpdateTemplateDto = { name: string; alias: string; status: string; - fromAddress: EmailsField; - subject: string; - replyTo: EmailsField; - htmlBody: string; - textBody: string; resendUpdatedAt: string; publishedAt: string | null; + fromAddress?: EmailsField; + subject?: string; + replyTo?: EmailsField; + htmlBody?: string; + textBody?: string; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/upsert-records-options.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/upsert-records-options.ts index a44025214f..0b0f13a8eb 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/upsert-records-options.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/types/upsert-records-options.ts @@ -2,16 +2,14 @@ import type { CoreApiClient } from 'twenty-client-sdk/core'; export type UpsertRecordsOptions< TListItem, - TDetail = TListItem, TCreateDto extends Record = Record, TUpdateDto extends Record = Record, > = { items: TListItem[]; getId: (item: TListItem) => string; - fetchDetail?: (id: string) => Promise; - mapCreateData: (detail: TDetail, item: TListItem) => TCreateDto; - mapUpdateData: (detail: TDetail, item: TListItem) => TUpdateDto; - existingMap: Map; + mapCreateData: (detail: TListItem, item: TListItem) => TCreateDto; + mapUpdateData: (detail: TListItem, item: TListItem) => TUpdateDto; client: CoreApiClient; objectNameSingular: string; + objectNamePlural: string; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/are-all-sync-cursors-empty.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/are-all-sync-cursors-empty.test.ts new file mode 100644 index 0000000000..ae7c4fe2ee --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/are-all-sync-cursors-empty.test.ts @@ -0,0 +1,85 @@ +import type { CoreApiClient } from 'twenty-client-sdk/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { areAllSyncCursorsEmpty } from '@modules/resend/sync/utils/are-all-sync-cursors-empty'; + +type CursorNode = { + step: string; + cursor: string | null; + lastRunStatus: 'SUCCESS' | 'FAILED' | 'IN_PROGRESS' | null; +}; + +const makeClient = (nodes: CursorNode[]) => + ({ + query: vi.fn(async () => ({ + resendSyncCursors: { + edges: nodes.map((node) => ({ node })), + }, + })), + mutation: vi.fn(), + }) as unknown as CoreApiClient; + +const allSuccessfulRows: CursorNode[] = [ + { step: 'TOPICS', cursor: null, lastRunStatus: 'SUCCESS' }, + { step: 'SEGMENTS', cursor: null, lastRunStatus: 'SUCCESS' }, + { step: 'TEMPLATES', cursor: null, lastRunStatus: 'SUCCESS' }, + { step: 'CONTACTS', cursor: null, lastRunStatus: 'SUCCESS' }, + { step: 'EMAILS', cursor: null, lastRunStatus: 'SUCCESS' }, + { step: 'BROADCASTS', cursor: null, lastRunStatus: 'SUCCESS' }, +]; + +describe('areAllSyncCursorsEmpty', () => { + it('returns true when every required step has a null cursor and SUCCESS status', async () => { + const client = makeClient(allSuccessfulRows); + + await expect(areAllSyncCursorsEmpty(client)).resolves.toBe(true); + }); + + it('returns true when a step is IN_PROGRESS but cursor is cleared', async () => { + const client = makeClient( + allSuccessfulRows.map((row) => + row.step === 'EMAILS' + ? { ...row, lastRunStatus: 'IN_PROGRESS' } + : row, + ), + ); + + await expect(areAllSyncCursorsEmpty(client)).resolves.toBe(true); + }); + + it('returns false when the TOPICS row is missing entirely', async () => { + const client = makeClient( + allSuccessfulRows.filter((row) => row.step !== 'TOPICS'), + ); + + await expect(areAllSyncCursorsEmpty(client)).resolves.toBe(false); + }); + + it('returns false when a step is missing a cursor row', async () => { + const client = makeClient( + allSuccessfulRows.filter((row) => row.step !== 'EMAILS'), + ); + + await expect(areAllSyncCursorsEmpty(client)).resolves.toBe(false); + }); + + it('returns false when a cursor is not yet drained', async () => { + const client = makeClient( + allSuccessfulRows.map((row) => + row.step === 'EMAILS' ? { ...row, cursor: 'resume-me' } : row, + ), + ); + + await expect(areAllSyncCursorsEmpty(client)).resolves.toBe(false); + }); + + it('returns false when last run failed', async () => { + const client = makeClient( + allSuccessfulRows.map((row) => + row.step === 'BROADCASTS' ? { ...row, lastRunStatus: 'FAILED' } : row, + ), + ); + + await expect(areAllSyncCursorsEmpty(client)).resolves.toBe(false); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/backfill-resend-contact-person-id.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/backfill-resend-contact-person-id.test.ts new file mode 100644 index 0000000000..e6922e81b3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/backfill-resend-contact-person-id.test.ts @@ -0,0 +1,133 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { backfillResendContactPersonId } from '@modules/resend/sync/utils/backfill-resend-contact-person-id'; + +type QueryMock = ReturnType; +type MutationMock = ReturnType; + +const buildClient = ( + query: QueryMock, + mutation: MutationMock, +): CoreApiClient => ({ query, mutation }) as unknown as CoreApiClient; + +describe('backfillResendContactPersonId', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns early without querying when no entries are provided', async () => { + const query = vi.fn(); + const mutation = vi.fn(); + + const result = await backfillResendContactPersonId( + buildClient(query, mutation), + new Map(), + ); + + expect(result).toEqual({ updated: 0, errors: [] }); + expect(query).not.toHaveBeenCalled(); + expect(mutation).not.toHaveBeenCalled(); + }); + + it('only updates contacts whose personId is null', async () => { + const query = vi.fn(async () => ({ + resendContacts: { + edges: [ + { + node: { + id: 'contact-1', + personId: null, + email: { primaryEmail: 'foo@example.com' }, + }, + }, + { + node: { + id: 'contact-2', + personId: 'existing-person', + email: { primaryEmail: 'bar@example.com' }, + }, + }, + ], + }, + })); + const mutation = vi.fn(async () => ({})); + + const result = await backfillResendContactPersonId( + buildClient(query, mutation), + new Map([ + ['foo@example.com', 'twenty-person-foo'], + ['bar@example.com', 'twenty-person-bar'], + ]), + ); + + expect(result.updated).toBe(1); + expect(result.errors).toEqual([]); + expect(mutation).toHaveBeenCalledTimes(1); + + const [call] = mutation.mock.calls as unknown as Array< + [{ updateResendContact: { __args: unknown } }] + >; + + expect(call?.[0].updateResendContact.__args).toEqual({ + id: 'contact-1', + data: { personId: 'twenty-person-foo' }, + }); + }); + + it('captures mutation errors per record without aborting the batch', async () => { + const query = vi.fn(async () => ({ + resendContacts: { + edges: [ + { + node: { + id: 'contact-1', + personId: null, + email: { primaryEmail: 'foo@example.com' }, + }, + }, + { + node: { + id: 'contact-2', + personId: null, + email: { primaryEmail: 'bar@example.com' }, + }, + }, + ], + }, + })); + + const mutation = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({}); + + const result = await backfillResendContactPersonId( + buildClient(query, mutation), + new Map([ + ['foo@example.com', 'twenty-person-foo'], + ['bar@example.com', 'twenty-person-bar'], + ]), + ); + + expect(result.updated).toBe(1); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('contact-1'); + expect(result.errors[0]).toContain('boom'); + }); + + it('captures lookup errors and returns immediately', async () => { + const query = vi.fn().mockRejectedValue(new Error('lookup failed')); + const mutation = vi.fn(); + + const result = await backfillResendContactPersonId( + buildClient(query, mutation), + new Map([['foo@example.com', 'twenty-person-foo']]), + ); + + expect(result.updated).toBe(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('lookup failed'); + expect(mutation).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/backfill-resend-emails-from-contacts.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/backfill-resend-emails-from-contacts.test.ts new file mode 100644 index 0000000000..f1853ae9b9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/backfill-resend-emails-from-contacts.test.ts @@ -0,0 +1,287 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { backfillResendEmailsFromContacts } from '@modules/resend/sync/utils/backfill-resend-emails-from-contacts'; + +type QueryMock = ReturnType; +type MutationMock = ReturnType; + +const buildClient = ( + query: QueryMock, + mutation: MutationMock, +): CoreApiClient => ({ query, mutation }) as unknown as CoreApiClient; + +describe('backfillResendEmailsFromContacts', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns early without querying when no entries are provided', async () => { + const query = vi.fn(); + const mutation = vi.fn(); + + const result = await backfillResendEmailsFromContacts( + buildClient(query, mutation), + new Map(), + ); + + expect(result).toEqual({ updated: 0, errors: [] }); + expect(query).not.toHaveBeenCalled(); + expect(mutation).not.toHaveBeenCalled(); + }); + + it('only fills the FK fields that are currently null and skips records where both are already set', async () => { + const query = vi.fn(async () => ({ + resendEmails: { + edges: [ + { + node: { + id: 'email-1', + contactId: null, + personId: null, + toAddresses: { primaryEmail: 'foo@example.com' }, + }, + }, + { + node: { + id: 'email-2', + contactId: 'existing-contact', + personId: 'existing-person', + toAddresses: { primaryEmail: 'foo@example.com' }, + }, + }, + { + node: { + id: 'email-3', + contactId: null, + personId: 'existing-person', + toAddresses: { primaryEmail: 'bar@example.com' }, + }, + }, + { + node: { + id: 'email-4', + contactId: 'existing-contact', + personId: null, + toAddresses: { primaryEmail: 'foo@example.com' }, + }, + }, + ], + }, + })); + const mutation = vi.fn(async () => ({})); + + const result = await backfillResendEmailsFromContacts( + buildClient(query, mutation), + new Map([ + [ + 'foo@example.com', + { contactId: 'twenty-contact-foo', personId: 'twenty-person-foo' }, + ], + ['bar@example.com', { contactId: 'twenty-contact-bar' }], + ]), + ); + + expect(result.updated).toBe(3); + expect(result.errors).toEqual([]); + expect(mutation).toHaveBeenCalledTimes(3); + + const callArgs = ( + mutation.mock.calls as unknown as Array< + [{ updateResendEmail: { __args: unknown } }] + > + ).map((call) => call[0].updateResendEmail.__args); + + expect(callArgs).toEqual([ + { + id: 'email-1', + data: { + contactId: 'twenty-contact-foo', + personId: 'twenty-person-foo', + }, + }, + { + id: 'email-3', + data: { contactId: 'twenty-contact-bar' }, + }, + { + id: 'email-4', + data: { personId: 'twenty-person-foo' }, + }, + ]); + }); + + it('captures mutation errors per record without aborting the batch', async () => { + const query = vi.fn(async () => ({ + resendEmails: { + edges: [ + { + node: { + id: 'email-1', + contactId: null, + personId: null, + toAddresses: { primaryEmail: 'foo@example.com' }, + }, + }, + { + node: { + id: 'email-2', + contactId: null, + personId: null, + toAddresses: { primaryEmail: 'foo@example.com' }, + }, + }, + ], + }, + })); + + const mutation = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({}); + + const result = await backfillResendEmailsFromContacts( + buildClient(query, mutation), + new Map([['foo@example.com', { contactId: 'twenty-contact-foo' }]]), + ); + + expect(result.updated).toBe(1); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('email-1'); + expect(result.errors[0]).toContain('boom'); + }); + + it('paginates the resendEmails lookup until pageInfo.hasNextPage is false', async () => { + const query = vi + .fn() + .mockResolvedValueOnce({ + resendEmails: { + edges: [ + { + node: { + id: 'email-page-1', + contactId: null, + personId: null, + toAddresses: { primaryEmail: 'foo@example.com' }, + }, + }, + ], + pageInfo: { hasNextPage: true, endCursor: 'cursor-1' }, + }, + }) + .mockResolvedValueOnce({ + resendEmails: { + edges: [ + { + node: { + id: 'email-page-2', + contactId: null, + personId: null, + toAddresses: { primaryEmail: 'foo@example.com' }, + }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: 'cursor-2' }, + }, + }); + const mutation = vi.fn(async () => ({})); + + const result = await backfillResendEmailsFromContacts( + buildClient(query, mutation), + new Map([['foo@example.com', { contactId: 'twenty-contact-foo' }]]), + ); + + expect(query).toHaveBeenCalledTimes(2); + expect(mutation).toHaveBeenCalledTimes(2); + expect(result.updated).toBe(2); + expect(result.errors).toEqual([]); + + const secondCallArgs = ( + query.mock.calls[1] as unknown as Array<{ + resendEmails: { __args: { after?: string } }; + }> + )[0].resendEmails.__args; + + expect(secondCallArgs.after).toBe('cursor-1'); + }); + + it('also rewrites toAddresses with normalized primaryEmail / additionalEmails when the stored values are mixed case', async () => { + const query = vi.fn(async () => ({ + resendEmails: { + edges: [ + { + node: { + id: 'email-mixed', + contactId: null, + personId: null, + toAddresses: { + primaryEmail: 'Foo@Example.com', + additionalEmails: ['Bar@EXAMPLE.com', 'baz@example.com'], + }, + }, + }, + { + node: { + id: 'email-already-normalized', + contactId: null, + personId: null, + toAddresses: { + primaryEmail: 'foo@example.com', + additionalEmails: null, + }, + }, + }, + ], + }, + })); + const mutation = vi.fn(async () => ({})); + + const result = await backfillResendEmailsFromContacts( + buildClient(query, mutation), + new Map([['foo@example.com', { contactId: 'twenty-contact-foo' }]]), + ); + + expect(result.updated).toBe(2); + expect(result.errors).toEqual([]); + + const callArgs = ( + mutation.mock.calls as unknown as Array< + [{ updateResendEmail: { __args: unknown } }] + > + ).map((call) => call[0].updateResendEmail.__args); + + expect(callArgs).toEqual([ + { + id: 'email-mixed', + data: { + contactId: 'twenty-contact-foo', + toAddresses: { + primaryEmail: 'foo@example.com', + additionalEmails: ['bar@example.com', 'baz@example.com'], + }, + }, + }, + { + id: 'email-already-normalized', + data: { + contactId: 'twenty-contact-foo', + }, + }, + ]); + }); + + it('captures lookup errors and returns immediately', async () => { + const query = vi.fn().mockRejectedValue(new Error('lookup failed')); + const mutation = vi.fn(); + + const result = await backfillResendEmailsFromContacts( + buildClient(query, mutation), + new Map([['foo@example.com', { contactId: 'twenty-contact-foo' }]]), + ); + + expect(result.updated).toBe(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('lookup failed'); + expect(mutation).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/find-recent-sent-broadcasts.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/find-recent-sent-broadcasts.test.ts new file mode 100644 index 0000000000..dd51ba4e40 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/find-recent-sent-broadcasts.test.ts @@ -0,0 +1,122 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { findRecentSentBroadcasts } from '@modules/resend/sync/utils/find-recent-sent-broadcasts'; + +type QueryMock = ReturnType; + +const buildClient = (query: QueryMock): CoreApiClient => + ({ query }) as unknown as CoreApiClient; + +describe('findRecentSentBroadcasts', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('queries resendBroadcasts with a sentAt gte filter', async () => { + const query = vi.fn(async () => ({ + resendBroadcasts: { + edges: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + })); + + await findRecentSentBroadcasts(buildClient(query), { + sinceIso: '2026-01-01T00:00:00.000Z', + }); + + expect(query).toHaveBeenCalledTimes(1); + + const queryArgs = ( + query.mock.calls[0] as unknown as Array<{ + resendBroadcasts: { __args: { filter: unknown; first: number } }; + }> + )[0].resendBroadcasts.__args; + + expect(queryArgs.filter).toEqual({ + sentAt: { gte: '2026-01-01T00:00:00.000Z' }, + }); + expect(queryArgs.first).toBeGreaterThan(0); + }); + + it('returns broadcasts sorted ascending by sentAtMs and skips invalid sentAt values', async () => { + const query = vi.fn(async () => ({ + resendBroadcasts: { + edges: [ + { node: { id: 'b-late', sentAt: '2026-01-01T11:00:00.000Z' } }, + { node: { id: 'b-early', sentAt: '2026-01-01T09:00:00.000Z' } }, + { node: { id: 'b-mid', sentAt: '2026-01-01T10:00:00.000Z' } }, + { node: { id: 'b-null', sentAt: null } }, + { node: { id: 'b-empty', sentAt: '' } }, + { node: { id: 'b-bad', sentAt: 'not-a-date' } }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + })); + + const result = await findRecentSentBroadcasts(buildClient(query), { + sinceIso: '2026-01-01T00:00:00.000Z', + }); + + expect(result.map((broadcast) => broadcast.id)).toEqual([ + 'b-early', + 'b-mid', + 'b-late', + ]); + expect(result[0].sentAtMs).toBe( + new Date('2026-01-01T09:00:00.000Z').getTime(), + ); + }); + + it('paginates until hasNextPage is false', async () => { + const query = vi + .fn() + .mockResolvedValueOnce({ + resendBroadcasts: { + edges: [ + { node: { id: 'b-1', sentAt: '2026-01-01T09:00:00.000Z' } }, + ], + pageInfo: { hasNextPage: true, endCursor: 'cursor-1' }, + }, + }) + .mockResolvedValueOnce({ + resendBroadcasts: { + edges: [ + { node: { id: 'b-2', sentAt: '2026-01-01T10:00:00.000Z' } }, + ], + pageInfo: { hasNextPage: false, endCursor: 'cursor-2' }, + }, + }); + + const result = await findRecentSentBroadcasts(buildClient(query), { + sinceIso: '2026-01-01T00:00:00.000Z', + }); + + expect(query).toHaveBeenCalledTimes(2); + + const secondCallArgs = ( + query.mock.calls[1] as unknown as Array<{ + resendBroadcasts: { __args: { after?: string } }; + }> + )[0].resendBroadcasts.__args; + + expect(secondCallArgs.after).toBe('cursor-1'); + expect(result.map((broadcast) => broadcast.id)).toEqual(['b-1', 'b-2']); + }); + + it('stops paginating when endCursor is null even if hasNextPage is true', async () => { + const query = vi.fn().mockResolvedValueOnce({ + resendBroadcasts: { + edges: [{ node: { id: 'b-1', sentAt: '2026-01-01T09:00:00.000Z' } }], + pageInfo: { hasNextPage: true, endCursor: null }, + }, + }); + + const result = await findRecentSentBroadcasts(buildClient(query), { + sinceIso: '2026-01-01T00:00:00.000Z', + }); + + expect(query).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/resolve-broadcast-id-for-email.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/resolve-broadcast-id-for-email.test.ts new file mode 100644 index 0000000000..d76f9c0ecf --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/resolve-broadcast-id-for-email.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { + BROADCAST_EMAIL_MATCH_WINDOW_MS, + resolveBroadcastIdForEmail, +} from '@modules/resend/sync/utils/resolve-broadcast-id-for-email'; + +const buildBroadcasts = ( + broadcasts: Array<[string, number]>, +): Array<{ id: string; sentAtMs: number }> => + broadcasts + .map(([id, sentAtMs]) => ({ id, sentAtMs })) + .sort((left, right) => left.sentAtMs - right.sentAtMs); + +describe('resolveBroadcastIdForEmail', () => { + it('returns undefined when there are no broadcasts', () => { + expect(resolveBroadcastIdForEmail(Date.now(), [])).toBeUndefined(); + }); + + it('returns undefined when emailCreatedAtMs is NaN', () => { + const broadcasts = buildBroadcasts([['b1', 1_000]]); + + expect(resolveBroadcastIdForEmail(Number.NaN, broadcasts)).toBeUndefined(); + }); + + it('picks the closest preceding broadcast within the 1h window', () => { + const baseSent = new Date('2026-01-01T10:00:00Z').getTime(); + const broadcasts = buildBroadcasts([ + ['old', baseSent - 30 * 60 * 1000], + ['recent', baseSent], + ]); + const emailCreatedAt = baseSent + 5 * 60 * 1000; + + expect(resolveBroadcastIdForEmail(emailCreatedAt, broadcasts)).toBe( + 'recent', + ); + }); + + it('returns undefined when the closest preceding broadcast is outside the 1h window', () => { + const broadcastSent = new Date('2026-01-01T10:00:00Z').getTime(); + const broadcasts = buildBroadcasts([['too-old', broadcastSent]]); + const emailCreatedAt = broadcastSent + BROADCAST_EMAIL_MATCH_WINDOW_MS + 1; + + expect( + resolveBroadcastIdForEmail(emailCreatedAt, broadcasts), + ).toBeUndefined(); + }); + + it('matches broadcasts whose sentAt equals the email createdAt (boundary lower bound)', () => { + const sentAt = new Date('2026-01-01T10:00:00Z').getTime(); + const broadcasts = buildBroadcasts([['exact', sentAt]]); + + expect(resolveBroadcastIdForEmail(sentAt, broadcasts)).toBe('exact'); + }); + + it('matches at the exact 1h upper bound', () => { + const sentAt = new Date('2026-01-01T10:00:00Z').getTime(); + const broadcasts = buildBroadcasts([['edge', sentAt]]); + const emailCreatedAt = sentAt + BROADCAST_EMAIL_MATCH_WINDOW_MS; + + expect(resolveBroadcastIdForEmail(emailCreatedAt, broadcasts)).toBe('edge'); + }); + + it('ignores broadcasts sent strictly after the email', () => { + const emailCreatedAt = new Date('2026-01-01T10:00:00Z').getTime(); + const broadcasts = buildBroadcasts([ + ['future', emailCreatedAt + 60 * 1000], + ]); + + expect( + resolveBroadcastIdForEmail(emailCreatedAt, broadcasts), + ).toBeUndefined(); + }); + + it('on identical sentAt, picks the broadcast that comes last in the sorted array (deterministic tiebreak)', () => { + const sentAt = new Date('2026-01-01T10:00:00Z').getTime(); + const broadcasts: Array<{ id: string; sentAtMs: number }> = [ + { id: 'first', sentAtMs: sentAt }, + { id: 'second', sentAtMs: sentAt }, + ]; + + expect(resolveBroadcastIdForEmail(sentAt + 60 * 1000, broadcasts)).toBe( + 'second', + ); + }); + + it('skips broadcasts sent after the email and returns the closest preceding one within the window', () => { + const baseSent = new Date('2026-01-01T10:00:00Z').getTime(); + const broadcasts = buildBroadcasts([ + ['preceding', baseSent], + ['later', baseSent + 30 * 60 * 1000], + ]); + const emailCreatedAt = baseSent + 10 * 60 * 1000; + + expect(resolveBroadcastIdForEmail(emailCreatedAt, broadcasts)).toBe( + 'preceding', + ); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/sync-broadcasts.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/sync-broadcasts.test.ts new file mode 100644 index 0000000000..0650effcdd --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/sync-broadcasts.test.ts @@ -0,0 +1,168 @@ +import type { Resend } from 'resend'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { syncBroadcasts } from '@modules/resend/sync/utils/sync-broadcasts'; + +vi.mock('@modules/resend/sync/utils/upsert-records', () => ({ + upsertRecords: vi.fn(), +})); + +vi.mock('@modules/resend/sync/cursor/utils/with-sync-cursor', () => ({ + withSyncCursor: async ( + _client: unknown, + _step: unknown, + fn: (ctx: { + resumeCursor: undefined; + onCursorAdvance: (cursor: string) => Promise; + }) => Promise, + ) => + fn({ + resumeCursor: undefined, + onCursorAdvance: async () => undefined, + }), +})); + +vi.mock('@modules/resend/shared/utils/with-rate-limit-retry', () => ({ + withRateLimitRetry: async (fn: () => Promise) => fn(), +})); + +vi.mock('@modules/resend/shared/utils/find-twenty-ids-by-resend-id', () => ({ + findTwentyIdsByResendId: vi.fn(), +})); + +import { findTwentyIdsByResendId } from '@modules/resend/shared/utils/find-twenty-ids-by-resend-id'; +import { upsertRecords } from '@modules/resend/sync/utils/upsert-records'; + +const mockUpsertRecords = upsertRecords as unknown as ReturnType; +const mockFindTwentyIdsByResendId = + findTwentyIdsByResendId as unknown as ReturnType; + +const buildResend = (pageBroadcasts: unknown[], detailById: Record): Resend => + ({ + broadcasts: { + list: vi.fn(async () => ({ + data: { data: pageBroadcasts, has_more: false }, + error: null, + })), + get: vi.fn(async (id: string) => ({ + data: detailById[id] ?? null, + error: detailById[id] ? null : { message: 'not found' }, + })), + }, + }) as unknown as Resend; + +describe('syncBroadcasts', () => { + beforeEach(() => { + mockUpsertRecords.mockReset(); + mockFindTwentyIdsByResendId.mockReset(); + }); + + it('captures html, text, and topicId from the broadcast detail', async () => { + const broadcast = { + id: 'broadcast-1', + name: 'Announcements', + segment_id: 'seg-1', + status: 'draft', + created_at: '2026-12-01T19:32:22.980Z', + scheduled_at: null, + sent_at: null, + }; + + const detail = { + ...broadcast, + from: 'Acme ', + subject: 'hello world', + reply_to: null, + preview_text: 'Check out our latest announcements', + html: '

Hello!

', + text: 'Hello!', + topic_id: 'topic-1', + }; + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 1, created: 1, updated: 0, errors: [] }, + ok: true, + twentyIdByResendId: new Map([['broadcast-1', 'twenty-broadcast-1']]), + }); + + mockFindTwentyIdsByResendId.mockImplementation( + async (_client: unknown, plural: string) => { + if (plural === 'resendSegments') { + return new Map([['seg-1', 'twenty-seg-1']]); + } + + if (plural === 'resendTopics') { + return new Map([['topic-1', 'twenty-topic-1']]); + } + + return new Map(); + }, + ); + + const resend = buildResend([broadcast], { 'broadcast-1': detail }); + + await syncBroadcasts(resend, {} as CoreApiClient); + + const upsertCall = mockUpsertRecords.mock.calls[0][0]; + const createDto = upsertCall.mapCreateData(undefined, broadcast); + const updateDto = upsertCall.mapUpdateData(undefined, broadcast); + + expect(createDto).toMatchObject({ + htmlBody: '

Hello!

', + textBody: 'Hello!', + segmentId: 'twenty-seg-1', + topicId: 'twenty-topic-1', + subject: 'hello world', + previewText: 'Check out our latest announcements', + }); + + expect(updateDto).toMatchObject({ + htmlBody: '

Hello!

', + textBody: 'Hello!', + segmentId: 'twenty-seg-1', + topicId: 'twenty-topic-1', + }); + }); + + it('sets topicId to null when the broadcast has no topic_id (update)', async () => { + const broadcast = { + id: 'broadcast-2', + name: 'No topic', + segment_id: null, + status: 'sent', + created_at: '2026-12-01T19:32:22.980Z', + scheduled_at: null, + sent_at: '2026-12-02T00:00:00Z', + }; + + const detail = { + ...broadcast, + from: 'sender@example.com', + subject: 'hi', + reply_to: null, + preview_text: '', + html: '', + text: '', + topic_id: null, + }; + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 1, created: 0, updated: 1, errors: [] }, + ok: true, + twentyIdByResendId: new Map(), + }); + + mockFindTwentyIdsByResendId.mockResolvedValue(new Map()); + + const resend = buildResend([broadcast], { 'broadcast-2': detail }); + + await syncBroadcasts(resend, {} as CoreApiClient); + + const upsertCall = mockUpsertRecords.mock.calls[0][0]; + const updateDto = upsertCall.mapUpdateData(undefined, broadcast); + + expect(updateDto.topicId).toBeNull(); + expect(updateDto.segmentId).toBeNull(); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/sync-contacts.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/sync-contacts.test.ts new file mode 100644 index 0000000000..22babe79a7 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/sync-contacts.test.ts @@ -0,0 +1,335 @@ +import type { Resend } from 'resend'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { syncContacts } from '@modules/resend/sync/utils/sync-contacts'; + +vi.mock('@modules/resend/sync/utils/upsert-records', () => ({ + upsertRecords: vi.fn(), +})); + +vi.mock('@modules/resend/sync/cursor/utils/with-sync-cursor', () => ({ + withSyncCursor: async ( + _client: unknown, + _step: unknown, + fn: (ctx: { + resumeCursor: undefined; + onCursorAdvance: (cursor: string) => Promise; + }) => Promise, + ) => + fn({ + resumeCursor: undefined, + onCursorAdvance: async () => undefined, + }), +})); + +vi.mock('@modules/resend/shared/utils/with-rate-limit-retry', () => ({ + withRateLimitRetry: async (fn: () => Promise) => fn(), +})); + +vi.mock('@modules/resend/shared/utils/find-people-by-email', () => ({ + findPeopleByEmail: vi.fn(), +})); + +vi.mock('@modules/resend/shared/utils/find-twenty-ids-by-resend-id', () => ({ + findTwentyIdsByResendId: vi.fn(), +})); + +vi.mock( + '@modules/resend/sync/utils/backfill-resend-emails-from-contacts', + () => ({ + backfillResendEmailsFromContacts: vi.fn(), + }), +); + +import { findPeopleByEmail } from '@modules/resend/shared/utils/find-people-by-email'; +import { findTwentyIdsByResendId } from '@modules/resend/shared/utils/find-twenty-ids-by-resend-id'; +import { backfillResendEmailsFromContacts } from '@modules/resend/sync/utils/backfill-resend-emails-from-contacts'; +import { upsertRecords } from '@modules/resend/sync/utils/upsert-records'; + +const mockUpsertRecords = upsertRecords as unknown as ReturnType; +const mockFindPeopleByEmail = findPeopleByEmail as unknown as ReturnType< + typeof vi.fn +>; +const mockFindTwentyIdsByResendId = + findTwentyIdsByResendId as unknown as ReturnType; +const mockBackfillResendEmailsFromContacts = + backfillResendEmailsFromContacts as unknown as ReturnType; + +const SYNCED_AT = '2026-01-01T00:00:00.000Z'; + +type ContactSegmentsByContactId = Record; + +const buildResend = ( + pageContacts: unknown[], + contactSegmentsByContactId: ContactSegmentsByContactId = {}, +): Resend => + ({ + contacts: { + list: vi.fn(async () => ({ + data: { data: pageContacts, has_more: false }, + error: null, + })), + segments: { + list: vi.fn(async ({ contactId }: { contactId: string }) => ({ + data: { + data: (contactSegmentsByContactId[contactId] ?? []).map((id) => ({ + id, + })), + has_more: false, + }, + error: null, + })), + }, + }, + }) as unknown as Resend; + +describe('syncContacts', () => { + beforeEach(() => { + mockUpsertRecords.mockReset(); + mockFindPeopleByEmail.mockReset(); + mockFindTwentyIdsByResendId.mockReset(); + mockFindTwentyIdsByResendId.mockResolvedValue(new Map()); + mockBackfillResendEmailsFromContacts.mockReset(); + mockBackfillResendEmailsFromContacts.mockResolvedValue({ + updated: 0, + errors: [], + }); + }); + + it('inlines personId into the upsert payload using a single batched lookup', async () => { + const pageContacts = [ + { + id: 'contact-1', + email: 'matched@example.com', + first_name: 'Matched', + last_name: 'Person', + unsubscribed: false, + created_at: '2026-01-01T00:00:00Z', + }, + { + id: 'contact-2', + email: 'unmatched@example.com', + first_name: 'Unmatched', + last_name: 'Person', + unsubscribed: true, + created_at: '2026-01-01T00:00:00Z', + }, + ]; + + mockFindPeopleByEmail.mockResolvedValue( + new Map([['matched@example.com', 'person-1']]), + ); + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 2, created: 2, updated: 0, errors: [] }, + ok: true, + twentyIdByResendId: new Map(), + }); + + const client = {} as CoreApiClient; + const resend = buildResend(pageContacts); + + await syncContacts(resend, client, SYNCED_AT); + + expect(mockFindPeopleByEmail).toHaveBeenCalledTimes(1); + + const upsertCall = mockUpsertRecords.mock.calls[0][0]; + + const matched = upsertCall.mapCreateData(undefined, pageContacts[0]); + const unmatched = upsertCall.mapCreateData(undefined, pageContacts[1]); + + expect(matched.personId).toBe('person-1'); + expect(unmatched.personId).toBeUndefined(); + }); + + it('backfills resendEmails with each upserted contact, including personId when known', async () => { + const pageContacts = [ + { + id: 'contact-1', + email: 'Matched@Example.com', + first_name: 'Matched', + last_name: 'Person', + unsubscribed: false, + created_at: '2026-01-01T00:00:00Z', + }, + { + id: 'contact-2', + email: 'unmatched@example.com', + first_name: 'Unmatched', + last_name: 'Person', + unsubscribed: true, + created_at: '2026-01-01T00:00:00Z', + }, + ]; + + mockFindPeopleByEmail.mockResolvedValue( + new Map([['matched@example.com', 'person-1']]), + ); + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 2, created: 2, updated: 0, errors: [] }, + ok: true, + twentyIdByResendId: new Map([ + ['contact-1', 'twenty-contact-1'], + ['contact-2', 'twenty-contact-2'], + ]), + }); + + const client = {} as CoreApiClient; + const resend = buildResend(pageContacts); + + await syncContacts(resend, client, SYNCED_AT); + + expect(mockBackfillResendEmailsFromContacts).toHaveBeenCalledTimes(1); + + const [, entriesByEmail] = + mockBackfillResendEmailsFromContacts.mock.calls[0]; + + expect(entriesByEmail).toEqual( + new Map([ + [ + 'matched@example.com', + { contactId: 'twenty-contact-1', personId: 'person-1' }, + ], + ['unmatched@example.com', { contactId: 'twenty-contact-2' }], + ]), + ); + }); + + it("inlines the first segment's Twenty id into the upsert payload when the contact has segments", async () => { + const pageContacts = [ + { + id: 'contact-1', + email: 'segmented@example.com', + first_name: 'Seg', + last_name: 'Mented', + unsubscribed: false, + created_at: '2026-01-01T00:00:00Z', + }, + { + id: 'contact-2', + email: 'unsegmented@example.com', + first_name: 'No', + last_name: 'Seg', + unsubscribed: false, + created_at: '2026-01-01T00:00:00Z', + }, + { + id: 'contact-3', + email: 'orphan@example.com', + first_name: 'Or', + last_name: 'Phan', + unsubscribed: false, + created_at: '2026-01-01T00:00:00Z', + }, + ]; + + mockFindPeopleByEmail.mockResolvedValue(new Map()); + + mockFindTwentyIdsByResendId.mockResolvedValue( + new Map([['resend-segment-1', 'twenty-segment-1']]), + ); + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 3, created: 3, updated: 0, errors: [] }, + ok: true, + twentyIdByResendId: new Map(), + }); + + const client = {} as CoreApiClient; + const resend = buildResend(pageContacts, { + 'contact-1': ['resend-segment-1', 'resend-segment-2'], + 'contact-3': ['resend-segment-unmapped'], + }); + + await syncContacts(resend, client, SYNCED_AT); + + expect(mockFindTwentyIdsByResendId).toHaveBeenCalledTimes(1); + expect(mockFindTwentyIdsByResendId.mock.calls[0][1]).toBe('resendSegments'); + expect( + Array.from(mockFindTwentyIdsByResendId.mock.calls[0][2]).sort(), + ).toEqual(['resend-segment-1', 'resend-segment-unmapped']); + + const upsertCall = mockUpsertRecords.mock.calls[0][0]; + + const segmented = upsertCall.mapCreateData(undefined, pageContacts[0]); + const unsegmented = upsertCall.mapCreateData(undefined, pageContacts[1]); + const orphan = upsertCall.mapCreateData(undefined, pageContacts[2]); + + expect(segmented.segmentId).toBe('twenty-segment-1'); + expect(unsegmented.segmentId).toBeUndefined(); + expect(orphan.segmentId).toBeUndefined(); + }); + + it('does not call findTwentyIdsByResendId when no contacts have segments', async () => { + const pageContacts = [ + { + id: 'contact-1', + email: 'one@example.com', + first_name: null, + last_name: null, + unsubscribed: false, + created_at: '2026-01-01T00:00:00Z', + }, + ]; + + mockFindPeopleByEmail.mockResolvedValue(new Map()); + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 1, created: 1, updated: 0, errors: [] }, + ok: true, + twentyIdByResendId: new Map(), + }); + + const client = {} as CoreApiClient; + const resend = buildResend(pageContacts); + + await syncContacts(resend, client, SYNCED_AT); + + expect(mockFindTwentyIdsByResendId).not.toHaveBeenCalled(); + }); + + it('skips backfill entries for contacts whose upsert did not return a Twenty id', async () => { + const pageContacts = [ + { + id: 'contact-1', + email: 'one@example.com', + first_name: null, + last_name: null, + unsubscribed: false, + created_at: '2026-01-01T00:00:00Z', + }, + { + id: 'contact-2', + email: 'two@example.com', + first_name: null, + last_name: null, + unsubscribed: false, + created_at: '2026-01-01T00:00:00Z', + }, + ]; + + mockFindPeopleByEmail.mockResolvedValue(new Map()); + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 2, created: 1, updated: 0, errors: [] }, + ok: true, + twentyIdByResendId: new Map([['contact-1', 'twenty-contact-1']]), + }); + + const client = {} as CoreApiClient; + const resend = buildResend(pageContacts); + + await syncContacts(resend, client, SYNCED_AT); + + expect(mockBackfillResendEmailsFromContacts).toHaveBeenCalledTimes(1); + + const [, entriesByEmail] = + mockBackfillResendEmailsFromContacts.mock.calls[0]; + + expect(entriesByEmail).toEqual( + new Map([['one@example.com', { contactId: 'twenty-contact-1' }]]), + ); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/sync-emails.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/sync-emails.test.ts new file mode 100644 index 0000000000..788728edbc --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/sync-emails.test.ts @@ -0,0 +1,376 @@ +import type { Resend } from 'resend'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { syncEmails } from '@modules/resend/sync/utils/sync-emails'; + +vi.mock('@modules/resend/sync/utils/upsert-records', () => ({ + upsertRecords: vi.fn(), +})); + +vi.mock('@modules/resend/sync/cursor/utils/with-sync-cursor', () => ({ + withSyncCursor: async ( + _client: unknown, + _step: unknown, + fn: (ctx: { + resumeCursor: undefined; + onCursorAdvance: (cursor: string) => Promise; + }) => Promise, + ) => + fn({ + resumeCursor: undefined, + onCursorAdvance: async () => undefined, + }), +})); + +vi.mock('@modules/resend/shared/utils/with-rate-limit-retry', () => ({ + withRateLimitRetry: async (fn: () => Promise) => fn(), +})); + +vi.mock('@modules/resend/shared/utils/find-people-by-email', () => ({ + findPeopleByEmail: vi.fn(), +})); + +vi.mock('@modules/resend/shared/utils/find-resend-contacts-by-email', () => ({ + findResendContactsByEmail: vi.fn(), +})); + +vi.mock('@modules/resend/sync/utils/backfill-resend-contact-person-id', () => ({ + backfillResendContactPersonId: vi.fn(), +})); + +vi.mock('@modules/resend/sync/utils/find-recent-sent-broadcasts', () => ({ + findRecentSentBroadcasts: vi.fn(), +})); + +import { findPeopleByEmail } from '@modules/resend/shared/utils/find-people-by-email'; +import { findResendContactsByEmail } from '@modules/resend/shared/utils/find-resend-contacts-by-email'; +import { backfillResendContactPersonId } from '@modules/resend/sync/utils/backfill-resend-contact-person-id'; +import { findRecentSentBroadcasts } from '@modules/resend/sync/utils/find-recent-sent-broadcasts'; +import { upsertRecords } from '@modules/resend/sync/utils/upsert-records'; + +const mockUpsertRecords = upsertRecords as unknown as ReturnType; +const mockFindPeopleByEmail = findPeopleByEmail as unknown as ReturnType< + typeof vi.fn +>; +const mockFindResendContactsByEmail = + findResendContactsByEmail as unknown as ReturnType; +const mockBackfillResendContactPersonId = + backfillResendContactPersonId as unknown as ReturnType; +const mockFindRecentSentBroadcasts = + findRecentSentBroadcasts as unknown as ReturnType; + +const SYNCED_AT = '2026-01-01T00:00:00.000Z'; + +const buildResend = (pageEmails: unknown[]): Resend => + ({ + emails: { + list: vi.fn(async () => ({ + data: { data: pageEmails, has_more: false }, + error: null, + })), + }, + }) as unknown as Resend; + +describe('syncEmails', () => { + beforeEach(() => { + mockUpsertRecords.mockReset(); + mockFindPeopleByEmail.mockReset(); + mockFindResendContactsByEmail.mockReset(); + mockBackfillResendContactPersonId.mockReset(); + mockBackfillResendContactPersonId.mockResolvedValue({ + updated: 0, + errors: [], + }); + mockFindRecentSentBroadcasts.mockReset(); + mockFindRecentSentBroadcasts.mockResolvedValue([]); + }); + + it('looks up people once per page and inlines personId into the upsert payload', async () => { + const pageEmails = [ + { + id: 'email-1', + subject: 'hello', + from: 'sender@example.com', + to: ['matched@example.com'], + cc: null, + bcc: null, + reply_to: null, + last_event: 'delivered', + created_at: '2026-01-01T00:00:00Z', + scheduled_at: null, + }, + { + id: 'email-2', + subject: 'world', + from: 'sender@example.com', + to: ['unmatched@example.com'], + cc: null, + bcc: null, + reply_to: null, + last_event: 'delivered', + created_at: '2026-01-01T00:00:00Z', + scheduled_at: null, + }, + ]; + + mockFindPeopleByEmail.mockResolvedValue( + new Map([['matched@example.com', 'person-1']]), + ); + mockFindResendContactsByEmail.mockResolvedValue(new Map()); + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 2, created: 2, updated: 0, errors: [] }, + ok: true, + twentyIdByResendId: new Map([ + ['email-1', 'twenty-email-1'], + ['email-2', 'twenty-email-2'], + ]), + }); + + const client = {} as CoreApiClient; + const resend = buildResend(pageEmails); + + await syncEmails(resend, client, SYNCED_AT); + + expect(mockFindPeopleByEmail).toHaveBeenCalledTimes(1); + expect(mockFindPeopleByEmail).toHaveBeenCalledWith(client, [ + 'matched@example.com', + 'unmatched@example.com', + ]); + + const upsertCall = mockUpsertRecords.mock.calls[0][0]; + + expect(upsertCall.items).toBe(pageEmails); + + const matchedDto = upsertCall.mapCreateData(undefined, pageEmails[0]); + const unmatchedDto = upsertCall.mapCreateData(undefined, pageEmails[1]); + + expect(matchedDto.personId).toBe('person-1'); + expect(unmatchedDto.personId).toBeUndefined(); + }); + + it('inlines contactId in the upsert payload when a resend contact matches the primary recipient', async () => { + const pageEmails = [ + { + id: 'email-1', + subject: 'hello', + from: 'sender@example.com', + to: ['matched@example.com'], + cc: null, + bcc: null, + reply_to: null, + last_event: 'delivered', + created_at: '2026-01-01T00:00:00Z', + scheduled_at: null, + }, + ]; + + mockFindPeopleByEmail.mockResolvedValue(new Map()); + mockFindResendContactsByEmail.mockResolvedValue( + new Map([ + [ + 'matched@example.com', + { id: 'twenty-contact-1', personId: 'person-1' }, + ], + ]), + ); + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 1, created: 1, updated: 0, errors: [] }, + ok: true, + twentyIdByResendId: new Map([['email-1', 'twenty-email-1']]), + }); + + const client = {} as CoreApiClient; + const resend = buildResend(pageEmails); + + await syncEmails(resend, client, SYNCED_AT); + + const upsertCall = mockUpsertRecords.mock.calls[0][0]; + const createDto = upsertCall.mapCreateData(undefined, pageEmails[0]); + const updateDto = upsertCall.mapUpdateData(undefined, pageEmails[0]); + + expect(createDto.contactId).toBe('twenty-contact-1'); + expect(updateDto.contactId).toBe('twenty-contact-1'); + }); + + it('backfills personId on matched resend contacts that have a null personId', async () => { + const pageEmails = [ + { + id: 'email-1', + subject: 'hello', + from: 'sender@example.com', + to: ['matched@example.com'], + cc: null, + bcc: null, + reply_to: null, + last_event: 'delivered', + created_at: '2026-01-01T00:00:00Z', + scheduled_at: null, + }, + { + id: 'email-2', + subject: 'hello again', + from: 'sender@example.com', + to: ['linked@example.com'], + cc: null, + bcc: null, + reply_to: null, + last_event: 'delivered', + created_at: '2026-01-01T00:00:00Z', + scheduled_at: null, + }, + ]; + + mockFindPeopleByEmail.mockResolvedValue( + new Map([ + ['matched@example.com', 'person-matched'], + ['linked@example.com', 'person-linked'], + ]), + ); + mockFindResendContactsByEmail.mockResolvedValue( + new Map([ + ['matched@example.com', { id: 'contact-matched', personId: null }], + [ + 'linked@example.com', + { id: 'contact-linked', personId: 'person-linked' }, + ], + ]), + ); + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 2, created: 2, updated: 0, errors: [] }, + ok: true, + twentyIdByResendId: new Map(), + }); + + const client = {} as CoreApiClient; + const resend = buildResend(pageEmails); + + await syncEmails(resend, client, SYNCED_AT); + + expect(mockBackfillResendContactPersonId).toHaveBeenCalledTimes(1); + + const [, personIdByEmail] = mockBackfillResendContactPersonId.mock.calls[0]; + + expect(personIdByEmail).toEqual( + new Map([['matched@example.com', 'person-matched']]), + ); + }); + + it('inlines broadcastId on create/update when the email was sent within an hour after a broadcast', async () => { + const broadcastSentAt = new Date('2026-01-01T10:00:00.000Z').getTime(); + const emailCreatedAt = '2026-01-01T10:30:00.000Z'; + const outOfWindowCreatedAt = '2026-01-01T12:00:00.000Z'; + + const pageEmails = [ + { + id: 'email-in-window', + subject: 'hello', + from: 'sender@example.com', + to: ['matched@example.com'], + cc: null, + bcc: null, + reply_to: null, + last_event: 'delivered', + created_at: emailCreatedAt, + scheduled_at: null, + }, + { + id: 'email-out-of-window', + subject: 'world', + from: 'sender@example.com', + to: ['matched@example.com'], + cc: null, + bcc: null, + reply_to: null, + last_event: 'delivered', + created_at: outOfWindowCreatedAt, + scheduled_at: null, + }, + ]; + + mockFindPeopleByEmail.mockResolvedValue(new Map()); + mockFindResendContactsByEmail.mockResolvedValue(new Map()); + mockFindRecentSentBroadcasts.mockResolvedValue([ + { id: 'twenty-broadcast-1', sentAtMs: broadcastSentAt }, + ]); + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 2, created: 2, updated: 0, errors: [] }, + ok: true, + twentyIdByResendId: new Map(), + }); + + const client = {} as CoreApiClient; + const resend = buildResend(pageEmails); + + await syncEmails(resend, client, SYNCED_AT); + + expect(mockFindRecentSentBroadcasts).toHaveBeenCalledTimes(1); + expect(mockFindRecentSentBroadcasts).toHaveBeenCalledWith(client, { + sinceIso: expect.any(String), + }); + + const upsertCall = mockUpsertRecords.mock.calls[0][0]; + + const inWindowCreate = upsertCall.mapCreateData(undefined, pageEmails[0]); + const inWindowUpdate = upsertCall.mapUpdateData(undefined, pageEmails[0]); + const outOfWindowCreate = upsertCall.mapCreateData(undefined, pageEmails[1]); + const outOfWindowUpdate = upsertCall.mapUpdateData(undefined, pageEmails[1]); + + expect(inWindowCreate.broadcastId).toBe('twenty-broadcast-1'); + expect(inWindowUpdate.broadcastId).toBe('twenty-broadcast-1'); + expect(outOfWindowCreate.broadcastId).toBeUndefined(); + expect(outOfWindowUpdate.broadcastId).toBeUndefined(); + }); + + it('picks the broadcast closest in time when multiple broadcasts precede the email within the window', async () => { + const earlierBroadcastSentAt = new Date( + '2026-01-01T10:00:00.000Z', + ).getTime(); + const closerBroadcastSentAt = new Date( + '2026-01-01T10:45:00.000Z', + ).getTime(); + const emailCreatedAt = '2026-01-01T11:00:00.000Z'; + + const pageEmails = [ + { + id: 'email-1', + subject: 'hello', + from: 'sender@example.com', + to: ['matched@example.com'], + cc: null, + bcc: null, + reply_to: null, + last_event: 'delivered', + created_at: emailCreatedAt, + scheduled_at: null, + }, + ]; + + mockFindPeopleByEmail.mockResolvedValue(new Map()); + mockFindResendContactsByEmail.mockResolvedValue(new Map()); + mockFindRecentSentBroadcasts.mockResolvedValue([ + { id: 'broadcast-earlier', sentAtMs: earlierBroadcastSentAt }, + { id: 'broadcast-closer', sentAtMs: closerBroadcastSentAt }, + ]); + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 1, created: 1, updated: 0, errors: [] }, + ok: true, + twentyIdByResendId: new Map(), + }); + + const client = {} as CoreApiClient; + const resend = buildResend(pageEmails); + + await syncEmails(resend, client, SYNCED_AT); + + const upsertCall = mockUpsertRecords.mock.calls[0][0]; + const createDto = upsertCall.mapCreateData(undefined, pageEmails[0]); + + expect(createDto.broadcastId).toBe('broadcast-closer'); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/sync-topics.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/sync-topics.test.ts new file mode 100644 index 0000000000..49ce986771 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/__tests__/sync-topics.test.ts @@ -0,0 +1,149 @@ +import type { Resend } from 'resend'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { syncTopics } from '@modules/resend/sync/utils/sync-topics'; + +vi.mock('@modules/resend/sync/utils/upsert-records', () => ({ + upsertRecords: vi.fn(), +})); + +vi.mock('@modules/resend/sync/cursor/utils/with-sync-cursor', () => ({ + withSyncCursor: async ( + _client: unknown, + _step: unknown, + fn: (ctx: { resumeCursor: undefined; onCursorAdvance: () => void }) => Promise, + ) => fn({ resumeCursor: undefined, onCursorAdvance: () => undefined }), +})); + +vi.mock('@modules/resend/shared/utils/with-rate-limit-retry', () => ({ + withRateLimitRetry: async (fn: () => Promise) => fn(), +})); + +import { upsertRecords } from '@modules/resend/sync/utils/upsert-records'; + +const mockUpsertRecords = upsertRecords as unknown as ReturnType; + +const SYNCED_AT = '2026-01-01T00:00:00.000Z'; + +const buildResend = ( + topicsList: Array<{ + id: string; + name: string; + description?: string; + default_subscription: string; + visibility?: string; + created_at: string; + }>, +): Resend => + ({ + topics: { + list: vi.fn(async () => ({ + data: { data: topicsList }, + error: null, + })), + }, + }) as unknown as Resend; + +const buildClient = (): CoreApiClient => ({}) as unknown as CoreApiClient; + +describe('syncTopics', () => { + beforeEach(() => { + mockUpsertRecords.mockReset(); + }); + + it('returns an empty id map when the API returns no topics', async () => { + const resend = buildResend([]); + const client = buildClient(); + + const { result, value } = await syncTopics(resend, client, SYNCED_AT); + + expect(value.size).toBe(0); + expect(result).toEqual({ + fetched: 0, + created: 0, + updated: 0, + errors: [], + }); + expect(mockUpsertRecords).not.toHaveBeenCalled(); + }); + + it('upserts topics with the correct DTO and returns the resend->twenty id map', async () => { + const resend = buildResend([ + { + id: 'resend-topic-1', + name: 'Weekly Newsletter', + description: 'Weekly newsletter', + default_subscription: 'opt_in', + visibility: 'public', + created_at: '2026-04-08T00:11:13.110779+00:00', + }, + ]); + const client = buildClient(); + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 1, created: 1, updated: 0, errors: [] }, + ok: true, + twentyIdByResendId: new Map([['resend-topic-1', 'twenty-topic-1']]), + }); + + const { value } = await syncTopics(resend, client, SYNCED_AT); + + expect(value.get('resend-topic-1')).toBe('twenty-topic-1'); + + const upsertCall = mockUpsertRecords.mock.calls[0][0]; + const dto = upsertCall.mapCreateData(undefined, upsertCall.items[0]); + + expect(dto).toEqual({ + name: 'Weekly Newsletter', + description: 'Weekly newsletter', + defaultSubscription: 'OPT_IN', + visibility: 'PUBLIC', + createdAt: '2026-04-08T00:11:13.110Z', + lastSyncedFromResend: SYNCED_AT, + }); + }); + + it('defaults visibility to PUBLIC when missing from the API response', async () => { + const resend = buildResend([ + { + id: 'resend-topic-2', + name: 'No-visibility topic', + default_subscription: 'opt_out', + created_at: '2026-04-08T00:11:13.110779+00:00', + }, + ]); + const client = buildClient(); + + mockUpsertRecords.mockResolvedValue({ + result: { fetched: 1, created: 1, updated: 0, errors: [] }, + ok: true, + twentyIdByResendId: new Map(), + }); + + await syncTopics(resend, client, SYNCED_AT); + + const upsertCall = mockUpsertRecords.mock.calls[0][0]; + const dto = upsertCall.mapCreateData(undefined, upsertCall.items[0]); + + expect(dto.visibility).toBe('PUBLIC'); + expect(dto.defaultSubscription).toBe('OPT_OUT'); + expect(dto.description).toBe(''); + }); + + it('throws when the Resend API returns an error', async () => { + const resend = { + topics: { + list: vi.fn(async () => ({ + data: null, + error: { message: 'boom' }, + })), + }, + } as unknown as Resend; + const client = buildClient(); + + await expect(syncTopics(resend, client, SYNCED_AT)).rejects.toThrow( + /Resend list\[topics\] failed/, + ); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/are-all-sync-cursors-empty.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/are-all-sync-cursors-empty.ts new file mode 100644 index 0000000000..098d9ecde9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/are-all-sync-cursors-empty.ts @@ -0,0 +1,55 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from '@utils/is-defined'; + +import { extractConnection } from '@modules/resend/shared/utils/typed-client'; +import { RESEND_SYNC_CURSOR_STEPS } from '@modules/resend/sync/cursor/constants/resend-sync-cursor-steps'; +import type { SyncCursorStep } from '@modules/resend/sync/cursor/types/sync-cursor-step'; + +type SyncCursorNode = { + step: SyncCursorStep; + cursor: string | null; + lastRunStatus: 'SUCCESS' | 'FAILED' | 'IN_PROGRESS' | null; +}; + +export const areAllSyncCursorsEmpty = async ( + client: CoreApiClient, +): Promise => { + const queryResult = await client.query({ + resendSyncCursors: { + __args: { + first: RESEND_SYNC_CURSOR_STEPS.length + 5, + }, + edges: { + node: { + step: true, + cursor: true, + lastRunStatus: true, + }, + }, + }, + }); + + const connection = extractConnection( + queryResult, + 'resendSyncCursors', + ); + + const rowByStep = new Map(); + + for (const edge of connection.edges) { + if (isDefined(edge.node?.step)) { + rowByStep.set(edge.node.step, edge.node); + } + } + + return RESEND_SYNC_CURSOR_STEPS.every((step) => { + const row = rowByStep.get(step); + + if (!isDefined(row)) return false; + + const cursorCleared = !isDefined(row.cursor) || row.cursor.length === 0; + const notFailed = row.lastRunStatus !== 'FAILED'; + + return cursorCleared && notFailed; + }); +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/backfill-resend-contact-person-id.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/backfill-resend-contact-person-id.ts new file mode 100644 index 0000000000..c340bd44ac --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/backfill-resend-contact-person-id.ts @@ -0,0 +1,99 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from '@utils/is-defined'; + +import { getErrorMessage } from '@modules/resend/shared/utils/get-error-message'; +import { extractConnection } from '@modules/resend/shared/utils/typed-client'; + +type ExistingResendContact = { + id: string; + personId?: string | null; + email?: { primaryEmail?: string | null } | null; +}; + +export type BackfillResendContactPersonIdResult = { + updated: number; + errors: string[]; +}; + +const normalize = (email: string): string => email.trim().toLowerCase(); + +export const backfillResendContactPersonId = async ( + client: CoreApiClient, + personIdByEmail: ReadonlyMap, +): Promise => { + const result: BackfillResendContactPersonIdResult = { + updated: 0, + errors: [], + }; + + const emailKeys = Array.from(personIdByEmail.keys()); + + if (emailKeys.length === 0) return result; + + let existingContacts: ExistingResendContact[]; + + try { + const queryResult = await client.query({ + resendContacts: { + __args: { + filter: { + email: { + primaryEmail: { in: emailKeys }, + }, + }, + first: emailKeys.length, + }, + edges: { + node: { + id: true, + personId: true, + email: { primaryEmail: true }, + }, + }, + }, + }); + + existingContacts = extractConnection( + queryResult, + 'resendContacts', + ).edges.map((edge) => edge.node); + } catch (error) { + result.errors.push( + `backfillResendContactPersonId lookup: ${getErrorMessage(error)}`, + ); + + return result; + } + + for (const contact of existingContacts) { + if (isDefined(contact.personId)) continue; + + const primaryEmail = contact.email?.primaryEmail; + + if (typeof primaryEmail !== 'string' || primaryEmail.length === 0) continue; + + const personId = personIdByEmail.get(normalize(primaryEmail)); + + if (!isDefined(personId)) continue; + + try { + await client.mutation({ + updateResendContact: { + __args: { + id: contact.id, + data: { personId }, + }, + id: true, + }, + }); + + result.updated++; + } catch (error) { + result.errors.push( + `backfillResendContactPersonId ${contact.id}: ${getErrorMessage(error)}`, + ); + } + } + + return result; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/backfill-resend-emails-from-contacts.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/backfill-resend-emails-from-contacts.ts new file mode 100644 index 0000000000..6867527819 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/backfill-resend-emails-from-contacts.ts @@ -0,0 +1,200 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from '@utils/is-defined'; + +import { TWENTY_PAGE_SIZE } from '@modules/resend/constants/sync-config'; +import { getErrorMessage } from '@modules/resend/shared/utils/get-error-message'; +import { extractConnection } from '@modules/resend/shared/utils/typed-client'; + +export type ContactBackfillEntry = { + contactId: string; + personId?: string; +}; + +type StoredEmailsField = { + primaryEmail?: string | null; + additionalEmails?: ReadonlyArray | null; +}; + +type ExistingResendEmail = { + id: string; + contactId?: string | null; + personId?: string | null; + toAddresses?: StoredEmailsField | null; +}; + +export type BackfillResendEmailsFromContactsResult = { + updated: number; + errors: string[]; +}; + +const normalize = (email: string): string => email.trim().toLowerCase(); + +const normalizeAdditionalEmails = ( + additionalEmails: ReadonlyArray | null | undefined, +): string[] | null => { + if (!Array.isArray(additionalEmails) || additionalEmails.length === 0) { + return null; + } + + return additionalEmails.map((email) => + typeof email === 'string' ? normalize(email) : email, + ); +}; + +const additionalEmailsDiffer = ( + current: ReadonlyArray | null | undefined, + next: ReadonlyArray | null, +): boolean => { + const currentArray = Array.isArray(current) ? current : null; + + if (currentArray === null && next === null) return false; + if (currentArray === null || next === null) return true; + if (currentArray.length !== next.length) return true; + + for (let index = 0; index < currentArray.length; index++) { + if (currentArray[index] !== next[index]) return true; + } + + return false; +}; + +export const backfillResendEmailsFromContacts = async ( + client: CoreApiClient, + entriesByEmail: ReadonlyMap, +): Promise => { + const result: BackfillResendEmailsFromContactsResult = { + updated: 0, + errors: [], + }; + + const emailKeys = Array.from(entriesByEmail.keys()); + + if (emailKeys.length === 0) return result; + + let hasNextPage = true; + let afterCursor: string | undefined; + + while (hasNextPage) { + let pageEmails: ExistingResendEmail[]; + let endCursor: string | null | undefined; + let pageHasNext: boolean; + + try { + const queryArgs: Record = { + filter: { + toAddresses: { + primaryEmail: { in: emailKeys }, + }, + }, + first: TWENTY_PAGE_SIZE, + }; + + if (isDefined(afterCursor)) { + queryArgs.after = afterCursor; + } + + const queryResult = await client.query({ + resendEmails: { + __args: queryArgs, + pageInfo: { + hasNextPage: true, + endCursor: true, + }, + edges: { + node: { + id: true, + contactId: true, + personId: true, + toAddresses: { + primaryEmail: true, + additionalEmails: true, + }, + }, + }, + }, + }); + + const connection = extractConnection( + queryResult, + 'resendEmails', + ); + + pageEmails = connection.edges.map((edge) => edge.node); + pageHasNext = connection.pageInfo?.hasNextPage ?? false; + endCursor = connection.pageInfo?.endCursor; + } catch (error) { + result.errors.push( + `backfillResendEmailsFromContacts lookup: ${getErrorMessage(error)}`, + ); + + return result; + } + + for (const email of pageEmails) { + const primaryRecipient = email.toAddresses?.primaryEmail; + + if ( + typeof primaryRecipient !== 'string' || + primaryRecipient.length === 0 + ) { + continue; + } + + const normalizedPrimary = normalize(primaryRecipient); + const entry = entriesByEmail.get(normalizedPrimary); + + if (!isDefined(entry)) continue; + + const data: Record = {}; + + if (!isDefined(email.contactId)) { + data.contactId = entry.contactId; + } + + if (!isDefined(email.personId) && isDefined(entry.personId)) { + data.personId = entry.personId; + } + + const normalizedAdditional = normalizeAdditionalEmails( + email.toAddresses?.additionalEmails, + ); + const primaryDiffers = primaryRecipient !== normalizedPrimary; + const additionalDiffers = additionalEmailsDiffer( + email.toAddresses?.additionalEmails, + normalizedAdditional, + ); + + if (primaryDiffers || additionalDiffers) { + data.toAddresses = { + primaryEmail: normalizedPrimary, + additionalEmails: normalizedAdditional, + }; + } + + if (Object.keys(data).length === 0) continue; + + try { + await client.mutation({ + updateResendEmail: { + __args: { + id: email.id, + data, + }, + id: true, + }, + }); + + result.updated++; + } catch (error) { + result.errors.push( + `backfillResendEmailsFromContacts ${email.id}: ${getErrorMessage(error)}`, + ); + } + } + + hasNextPage = pageHasNext && isDefined(endCursor); + afterCursor = endCursor ?? undefined; + } + + return result; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/fetch-existing-twenty-ids.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/fetch-existing-twenty-ids.ts new file mode 100644 index 0000000000..988c7d34f6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/fetch-existing-twenty-ids.ts @@ -0,0 +1,52 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from '@utils/is-defined'; + +import { TWENTY_PAGE_SIZE } from '@modules/resend/constants/sync-config'; +import { extractConnection } from '@modules/resend/shared/utils/typed-client'; + +type ExistingRecord = { + id: string; + resendId: string; +}; + +export const fetchExistingTwentyIdsByResendIds = async ( + client: CoreApiClient, + objectNamePlural: string, + resendIds: ReadonlyArray, +): Promise> => { + const map = new Map(); + + if (resendIds.length === 0) { + return map; + } + + const result = await client.query({ + [objectNamePlural]: { + __args: { + filter: { + resendId: { in: [...resendIds] }, + }, + first: Math.max(resendIds.length, TWENTY_PAGE_SIZE), + }, + edges: { + node: { + id: true, + resendId: true, + }, + }, + }, + }); + + const connection = extractConnection( + result, + objectNamePlural, + ); + + for (const edge of connection.edges) { + if (isDefined(edge.node.resendId)) { + map.set(edge.node.resendId, edge.node.id); + } + } + + return map; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/find-or-create-resend-segment.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/find-or-create-resend-segment.ts index 39eac1b860..355b46510c 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/find-or-create-resend-segment.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/find-or-create-resend-segment.ts @@ -1,32 +1,45 @@ import type { Resend } from 'resend'; import { CoreApiClient } from 'twenty-client-sdk/core'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; -import { fetchAllPaginated } from 'src/modules/resend/shared/utils/fetch-all-paginated'; -import { findRecordByResendId } from 'src/modules/resend/shared/utils/find-record-by-resend-id'; +import { findRecordByResendId } from '@modules/resend/shared/utils/find-record-by-resend-id'; +import { forEachPage } from '@modules/resend/shared/utils/for-each-page'; export const findOrCreateResendSegment = async ( resend: Resend, client: CoreApiClient, name: string, ): Promise => { - const existingSegments = await fetchAllPaginated( - (params) => resend.segments.list(params), + let unlinkedMatchId: string | undefined; + + await forEachPage( + (paginationParameters) => resend.segments.list(paginationParameters), + async (pageSegments) => { + for (const candidate of pageSegments) { + if (candidate.name !== name) { + continue; + } + + const linkedRecordId = await findRecordByResendId( + client, + 'resendSegments', + candidate.id, + ); + + if (!isDefined(linkedRecordId)) { + unlinkedMatchId = candidate.id; + + return { ok: true, stop: true }; + } + } + + return { ok: true }; + }, 'segments', ); - for (const candidate of existingSegments.filter( - (segment) => segment.name === name, - )) { - const linkedRecordId = await findRecordByResendId( - client, - 'resendSegments', - candidate.id, - ); - - if (!isDefined(linkedRecordId)) { - return candidate.id; - } + if (isDefined(unlinkedMatchId)) { + return unlinkedMatchId; } const { data, error } = await resend.segments.create({ name }); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/find-recent-sent-broadcasts.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/find-recent-sent-broadcasts.ts new file mode 100644 index 0000000000..40c1c7a9b5 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/find-recent-sent-broadcasts.ts @@ -0,0 +1,82 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from '@utils/is-defined'; + +import { TWENTY_PAGE_SIZE } from '@modules/resend/constants/sync-config'; +import { extractConnection } from '@modules/resend/shared/utils/typed-client'; + +export type SentBroadcast = { + id: string; + sentAtMs: number; +}; + +type SentBroadcastNode = { + id: string; + sentAt?: string | null; +}; + +export const findRecentSentBroadcasts = async ( + client: CoreApiClient, + { sinceIso }: { sinceIso: string }, +): Promise => { + const broadcasts: SentBroadcast[] = []; + + let hasNextPage = true; + let afterCursor: string | undefined; + + while (hasNextPage) { + const queryArgs: Record = { + filter: { + sentAt: { gte: sinceIso }, + }, + first: TWENTY_PAGE_SIZE, + }; + + if (isDefined(afterCursor)) { + queryArgs.after = afterCursor; + } + + const queryResult = await client.query({ + resendBroadcasts: { + __args: queryArgs, + pageInfo: { + hasNextPage: true, + endCursor: true, + }, + edges: { + node: { + id: true, + sentAt: true, + }, + }, + }, + }); + + const connection = extractConnection( + queryResult, + 'resendBroadcasts', + ); + + for (const edge of connection.edges) { + const { id, sentAt } = edge.node; + + if (typeof sentAt !== 'string' || sentAt.length === 0) continue; + + const sentAtMs = new Date(sentAt).getTime(); + + if (Number.isNaN(sentAtMs)) continue; + + broadcasts.push({ id, sentAtMs }); + } + + hasNextPage = connection.pageInfo?.hasNextPage ?? false; + afterCursor = connection.pageInfo?.endCursor ?? undefined; + + if (!isDefined(afterCursor)) { + hasNextPage = false; + } + } + + broadcasts.sort((left, right) => left.sentAtMs - right.sentAtMs); + + return broadcasts; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/get-existing-records-map.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/get-existing-records-map.ts index ed65e22424..ba4f1ca508 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/get-existing-records-map.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/get-existing-records-map.ts @@ -1,23 +1,14 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; -const PAGE_SIZE = 100; +import { TWENTY_PAGE_SIZE } from '@modules/resend/constants/sync-config'; +import { extractConnection } from '@modules/resend/shared/utils/typed-client'; type ExistingRecord = { id: string; resendId: string; }; -type PageInfo = { - hasNextPage: boolean; - endCursor: string | null; -}; - -type ConnectionResult = { - pageInfo: PageInfo; - edges: Array<{ node: ExistingRecord }>; -}; - export const getExistingRecordsMap = async ( client: CoreApiClient, objectNamePlural: string, @@ -29,15 +20,17 @@ export const getExistingRecordsMap = async ( let afterCursor: string | undefined; while (hasNextPage) { - const args: Record = { first: PAGE_SIZE }; + const connectionArguments: Record = { + first: TWENTY_PAGE_SIZE, + }; if (isDefined(afterCursor)) { - args.after = afterCursor; + connectionArguments.after = afterCursor; } const result = await client.query({ [objectNamePlural]: { - __args: args, + __args: connectionArguments, pageInfo: { hasNextPage: true, endCursor: true, @@ -51,20 +44,19 @@ export const getExistingRecordsMap = async ( }, }); - const connection = (result as Record)[objectNamePlural] as - | ConnectionResult - | undefined; + const connection = extractConnection( + result, + objectNamePlural, + ); - const edges = connection?.edges ?? []; - - for (const edge of edges) { + for (const edge of connection.edges) { if (isDefined(edge.node.resendId)) { map.set(edge.node.resendId, edge.node.id); } } - hasNextPage = connection?.pageInfo.hasNextPage ?? false; - afterCursor = connection?.pageInfo.endCursor ?? undefined; + hasNextPage = connection.pageInfo?.hasNextPage ?? false; + afterCursor = connection.pageInfo?.endCursor ?? undefined; } return map; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/log-step-outcome.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/log-step-outcome.ts index a503b23406..475cda0307 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/log-step-outcome.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/log-step-outcome.ts @@ -1,4 +1,4 @@ -import type { StepOutcome } from 'src/modules/resend/sync/types/step-outcome'; +import type { StepOutcome } from '@modules/resend/sync/types/step-outcome'; export const logStepOutcome = (outcome: StepOutcome): void => { if (outcome.status === 'ok') { diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/orchestrate-sync-resend.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/orchestrate-sync-resend.ts deleted file mode 100644 index 36a21d2f11..0000000000 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/orchestrate-sync-resend.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { StepOutcome } from 'src/modules/resend/sync/types/step-outcome'; -import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result'; -import { - runSyncStep, - skipDueToFailedDeps, -} from 'src/modules/resend/sync/utils/run-sync-step'; -import type { SegmentIdMap } from 'src/modules/resend/sync/utils/sync-segments'; - -export type SyncResendDeps = { - syncSegments: () => Promise>; - syncTemplates: () => Promise; - syncContacts: () => Promise; - syncEmails: () => Promise; - syncBroadcasts: (segmentMap: SegmentIdMap) => Promise; -}; - -export const orchestrateSyncResend = async ( - deps: SyncResendDeps, -): Promise>> => { - const [segments, templates, contacts, emails] = await Promise.all([ - runSyncStep('segments', deps.syncSegments), - runSyncStep('templates', deps.syncTemplates), - runSyncStep('contacts', deps.syncContacts), - runSyncStep('emails', deps.syncEmails), - ]); - - const broadcasts = - segments.status === 'ok' - ? await runSyncStep('broadcasts', () => deps.syncBroadcasts(segments.value)) - : skipDueToFailedDeps('broadcasts', { segments }); - - return [segments, templates, contacts, emails, broadcasts]; -}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/report-and-throw-if-errors.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/report-and-throw-if-errors.ts deleted file mode 100644 index a4c654d55f..0000000000 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/report-and-throw-if-errors.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { StepOutcome } from 'src/modules/resend/sync/types/step-outcome'; - -export const MAX_ERRORS_IN_THROWN_MESSAGE = 20; - -type AggregatedError = { - step: string; - message: string; -}; - -const collectErrors = ( - outcomes: ReadonlyArray>, -): AggregatedError[] => { - const errors: AggregatedError[] = []; - - for (const outcome of outcomes) { - if (outcome.status === 'failed') { - errors.push({ step: outcome.name, message: outcome.error }); - - continue; - } - - if (outcome.status === 'ok') { - for (const error of outcome.result.errors) { - errors.push({ step: outcome.name, message: error }); - } - } - } - - return errors; -}; - -export const reportAndThrowIfErrors = ( - outcomes: ReadonlyArray>, -): void => { - const errors = collectErrors(outcomes); - - if (errors.length === 0) { - return; - } - - const head = errors - .slice(0, MAX_ERRORS_IN_THROWN_MESSAGE) - .map(({ step, message }) => ` - [${step}] ${message}`) - .join('\n'); - - const remaining = errors.length - MAX_ERRORS_IN_THROWN_MESSAGE; - const suffix = remaining > 0 ? `\n ...and ${remaining} more` : ''; - - throw new Error( - `Sync completed with ${errors.length} error(s):\n${head}${suffix}`, - ); -}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/resolve-broadcast-id-for-email.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/resolve-broadcast-id-for-email.ts new file mode 100644 index 0000000000..b4d4522e41 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/resolve-broadcast-id-for-email.ts @@ -0,0 +1,47 @@ +import type { SentBroadcast } from '@modules/resend/sync/utils/find-recent-sent-broadcasts'; + +export const BROADCAST_EMAIL_MATCH_WINDOW_MS = 60 * 60 * 1000; + +const findLargestIndexAtOrBefore = ( + sortedBroadcasts: ReadonlyArray, + emailCreatedAtMs: number, +): number => { + let low = 0; + let high = sortedBroadcasts.length - 1; + let result = -1; + + while (low <= high) { + const mid = (low + high) >>> 1; + + if (sortedBroadcasts[mid].sentAtMs <= emailCreatedAtMs) { + result = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return result; +}; + +export const resolveBroadcastIdForEmail = ( + emailCreatedAtMs: number, + sortedBroadcasts: ReadonlyArray, +): string | undefined => { + if (sortedBroadcasts.length === 0) return undefined; + if (Number.isNaN(emailCreatedAtMs)) return undefined; + + const candidateIndex = findLargestIndexAtOrBefore( + sortedBroadcasts, + emailCreatedAtMs, + ); + + if (candidateIndex === -1) return undefined; + + const candidate = sortedBroadcasts[candidateIndex]; + const delta = emailCreatedAtMs - candidate.sentAtMs; + + if (delta > BROADCAST_EMAIL_MATCH_WINDOW_MS) return undefined; + + return candidate.id; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/run-sync-step.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/run-sync-step.ts index 6d97c4b2fd..a5c81244cf 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/run-sync-step.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/run-sync-step.ts @@ -1,15 +1,15 @@ -import type { StepOutcome } from 'src/modules/resend/sync/types/step-outcome'; -import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result'; -import { getErrorMessage } from 'src/modules/resend/shared/utils/get-error-message'; +import type { StepOutcome } from '@modules/resend/sync/types/step-outcome'; +import type { SyncStepResult } from '@modules/resend/sync/types/sync-step-result'; +import { getErrorMessage } from '@modules/resend/shared/utils/get-error-message'; export const runSyncStep = async ( name: string, - fn: () => Promise>, + executeStep: () => Promise>, ): Promise> => { const startedAt = performance.now(); try { - const { result, value } = await fn(); + const { result, value } = await executeStep(); return { name, @@ -28,13 +28,13 @@ export const runSyncStep = async ( } }; -export const skipDueToFailedDeps = ( +export const skipDueToFailedDependencies = ( name: string, - deps: Record>, + prerequisiteOutcomes: Record>, ): StepOutcome => { - const failed = Object.entries(deps) + const failed = Object.entries(prerequisiteOutcomes) .filter(([, outcome]) => outcome.status !== 'ok') - .map(([depName]) => depName); + .map(([prerequisiteName]) => prerequisiteName); return { name, diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/set-initial-sync-mode.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/set-initial-sync-mode.ts new file mode 100644 index 0000000000..0bb032656b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/set-initial-sync-mode.ts @@ -0,0 +1,71 @@ +import { isDefined } from '@utils/is-defined'; +import { MetadataApiClient } from 'twenty-client-sdk/metadata'; + +import { INITIAL_SYNC_MODE_ENV_VAR_NAME } from '@modules/resend/constants/sync-config'; + +export type InitialSyncModeValue = 'true' | 'false'; + +const getApplicationId = (): string => { + const applicationId = process.env.APPLICATION_ID; + + if (typeof applicationId !== 'string' || applicationId.length === 0) { + throw new Error( + 'APPLICATION_ID is not available in the logic function environment', + ); + } + + return applicationId; +}; + +export const setInitialSyncMode = async ( + value: InitialSyncModeValue, +): Promise => { + const applicationId = getApplicationId(); + + const metadataClient = new MetadataApiClient(); + + await metadataClient.mutation({ + updateOneApplicationVariable: { + __args: { + key: INITIAL_SYNC_MODE_ENV_VAR_NAME, + value, + applicationId, + }, + }, + }); +}; + +export const isInitialSyncModeOn = async (): Promise => { + try { + const applicationId = getApplicationId(); + const metadataClient = new MetadataApiClient(); + + const result = await metadataClient.query({ + findOneApplication: { + __args: { id: applicationId }, + applicationVariables: { + key: true, + value: true, + }, + }, + }); + + const variables = result?.findOneApplication?.applicationVariables; + + if (Array.isArray(variables)) { + const variable = variables.find( + (item) => item?.key === INITIAL_SYNC_MODE_ENV_VAR_NAME, + ); + + if (isDefined(variable?.value)) { + return variable.value === 'true'; + } + } + } catch (error) { + console.warn( + `[resend] failed to read INITIAL_SYNC_MODE from ApplicationVariable; falling back to env var: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + return process.env[INITIAL_SYNC_MODE_ENV_VAR_NAME] === 'true'; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/summarise-outcomes.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/summarise-outcomes.ts new file mode 100644 index 0000000000..dd4b683310 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/summarise-outcomes.ts @@ -0,0 +1,59 @@ +import type { StepOutcome } from '@modules/resend/sync/types/step-outcome'; + +export type SyncSummaryStep = { + name: string; + status: 'ok' | 'failed' | 'skipped'; + fetched: number; + created: number; + updated: number; + errorCount: number; + durationMs: number; +}; + +export const summariseOutcomes = ( + outcomes: ReadonlyArray>, +): { totalDurationMs: number; steps: SyncSummaryStep[] } => { + let totalDurationMs = 0; + + const steps: SyncSummaryStep[] = outcomes.map((outcome) => { + if (outcome.status === 'ok') { + totalDurationMs += outcome.durationMs; + + return { + name: outcome.name, + status: 'ok', + fetched: outcome.result.fetched, + created: outcome.result.created, + updated: outcome.result.updated, + errorCount: outcome.result.errors.length, + durationMs: outcome.durationMs, + }; + } + + if (outcome.status === 'failed') { + totalDurationMs += outcome.durationMs; + + return { + name: outcome.name, + status: 'failed', + fetched: 0, + created: 0, + updated: 0, + errorCount: 1, + durationMs: outcome.durationMs, + }; + } + + return { + name: outcome.name, + status: 'skipped', + fetched: 0, + created: 0, + updated: 0, + errorCount: 0, + durationMs: 0, + }; + }); + + return { totalDurationMs, steps }; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-broadcasts.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-broadcasts.ts index 3d39a2d25a..73e8713ca8 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-broadcasts.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-broadcasts.ts @@ -1,96 +1,233 @@ import type { Resend } from 'resend'; import { CoreApiClient } from 'twenty-client-sdk/core'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; -import type { CreateBroadcastDto } from 'src/modules/resend/sync/types/create-broadcast.dto'; -import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result'; -import type { UpdateBroadcastDto } from 'src/modules/resend/sync/types/update-broadcast.dto'; -import { fetchAllPaginated } from 'src/modules/resend/shared/utils/fetch-all-paginated'; -import { getExistingRecordsMap } from 'src/modules/resend/sync/utils/get-existing-records-map'; -import type { SegmentIdMap } from 'src/modules/resend/sync/utils/sync-segments'; -import { toEmailsField } from 'src/modules/resend/shared/utils/to-emails-field'; +import { findTwentyIdsByResendId } from '@modules/resend/shared/utils/find-twenty-ids-by-resend-id'; +import { forEachPage } from '@modules/resend/shared/utils/for-each-page'; +import { getErrorMessage } from '@modules/resend/shared/utils/get-error-message'; +import { toEmailsField } from '@modules/resend/shared/utils/to-emails-field'; import { toIsoString, toIsoStringOrNull, -} from 'src/modules/resend/shared/utils/to-iso-string'; -import { upsertRecords } from 'src/modules/resend/sync/utils/upsert-records'; +} from '@modules/resend/shared/utils/to-iso-string'; +import { withRateLimitRetry } from '@modules/resend/shared/utils/with-rate-limit-retry'; +import { withSyncCursor } from '@modules/resend/sync/cursor/utils/with-sync-cursor'; +import type { CreateBroadcastDto } from '@modules/resend/sync/types/create-broadcast.dto'; +import type { SyncResult } from '@modules/resend/sync/types/sync-result'; +import type { SyncStepResult } from '@modules/resend/sync/types/sync-step-result'; +import type { UpdateBroadcastDto } from '@modules/resend/sync/types/update-broadcast.dto'; +import { upsertRecords } from '@modules/resend/sync/utils/upsert-records'; + +type BroadcastDetail = Awaited< + ReturnType +>['data']; + +const fetchBroadcastDetailsForPage = async ( + resend: Resend, + pageBroadcasts: ReadonlyArray<{ id: string }>, + errors: string[], +): Promise>> => { + const detailByResendId = new Map>(); + + for (const broadcast of pageBroadcasts) { + try { + const { data: detail, error } = await withRateLimitRetry( + () => resend.broadcasts.get(broadcast.id), + { channel: 'broadcasts-detail' }, + ); + + if (isDefined(error) || !isDefined(detail)) { + errors.push( + `resendBroadcast ${broadcast.id} detail: ${JSON.stringify(error)}`, + ); + continue; + } + + detailByResendId.set(broadcast.id, detail); + } catch (error) { + errors.push( + `resendBroadcast ${broadcast.id} detail: ${getErrorMessage(error)}`, + ); + } + } + + return detailByResendId; +}; + +export type SyncBroadcastsOptions = { + deadlineAtMs?: number; +}; export const syncBroadcasts = async ( resend: Resend, client: CoreApiClient, - segmentMap: SegmentIdMap, + options?: SyncBroadcastsOptions, ): Promise => { - const broadcasts = await fetchAllPaginated( - (params) => resend.broadcasts.list(params), - 'broadcasts', + const aggregate: SyncResult = { + fetched: 0, + created: 0, + updated: 0, + errors: [], + }; + + await withSyncCursor( + client, + 'BROADCASTS', + async ({ resumeCursor, onCursorAdvance }) => { + const { completed } = await forEachPage( + (paginationParameters) => resend.broadcasts.list(paginationParameters), + async (pageBroadcasts) => { + const detailByResendId = await fetchBroadcastDetailsForPage( + resend, + pageBroadcasts, + aggregate.errors, + ); + + const segmentResendIds = pageBroadcasts + .map((broadcast) => broadcast.segment_id) + .filter( + (segmentId): segmentId is string => + typeof segmentId === 'string' && segmentId.length > 0, + ); + + const topicResendIds = Array.from(detailByResendId.values()) + .map((detail) => detail.topic_id) + .filter( + (topicId): topicId is string => + typeof topicId === 'string' && topicId.length > 0, + ); + + const [segmentMap, topicMap] = await Promise.all([ + segmentResendIds.length > 0 + ? findTwentyIdsByResendId( + client, + 'resendSegments', + segmentResendIds, + ) + : Promise.resolve(new Map()), + topicResendIds.length > 0 + ? findTwentyIdsByResendId( + client, + 'resendTopics', + topicResendIds, + ) + : Promise.resolve(new Map()), + ]); + + const pageOutcome = await upsertRecords({ + items: pageBroadcasts, + getId: (broadcast) => broadcast.id, + mapCreateData: (_detail, broadcast): CreateBroadcastDto => { + const data: CreateBroadcastDto = { + name: broadcast.name, + status: broadcast.status.toUpperCase(), + createdAt: toIsoString(broadcast.created_at), + scheduledAt: toIsoStringOrNull(broadcast.scheduled_at), + sentAt: toIsoStringOrNull(broadcast.sent_at), + }; + + if (isDefined(broadcast.segment_id)) { + const segmentId = segmentMap.get(broadcast.segment_id); + + if (isDefined(segmentId)) { + data.segmentId = segmentId; + } + } + + const detail = detailByResendId.get(broadcast.id); + + if (isDefined(detail)) { + data.subject = detail.subject ?? ''; + data.fromAddress = toEmailsField(detail.from); + data.replyTo = toEmailsField(detail.reply_to); + data.previewText = detail.preview_text ?? ''; + data.htmlBody = detail.html ?? ''; + data.textBody = detail.text ?? ''; + + if (isDefined(detail.topic_id)) { + const topicId = topicMap.get(detail.topic_id); + + if (isDefined(topicId)) { + data.topicId = topicId; + } + } + } + + return data; + }, + mapUpdateData: (_detail, broadcast): UpdateBroadcastDto => { + const data: UpdateBroadcastDto = { + status: broadcast.status.toUpperCase(), + scheduledAt: toIsoStringOrNull(broadcast.scheduled_at), + sentAt: toIsoStringOrNull(broadcast.sent_at), + }; + + if (!isDefined(broadcast.segment_id)) { + data.segmentId = null; + } else { + const segmentId = segmentMap.get(broadcast.segment_id); + + if (isDefined(segmentId)) { + data.segmentId = segmentId; + } else { + console.warn( + `[sync] broadcast ${broadcast.id}: segment ${broadcast.segment_id} not found in lookup map; leaving segmentId untouched`, + ); + } + } + + const detail = detailByResendId.get(broadcast.id); + + if (isDefined(detail)) { + data.subject = detail.subject ?? ''; + data.fromAddress = toEmailsField(detail.from); + data.replyTo = toEmailsField(detail.reply_to); + data.previewText = detail.preview_text ?? ''; + data.htmlBody = detail.html ?? ''; + data.textBody = detail.text ?? ''; + + if (!isDefined(detail.topic_id)) { + data.topicId = null; + } else { + const topicId = topicMap.get(detail.topic_id); + + if (isDefined(topicId)) { + data.topicId = topicId; + } else { + console.warn( + `[sync] broadcast ${broadcast.id}: topic ${detail.topic_id} not found in lookup map; leaving topicId untouched`, + ); + } + } + } + + return data; + }, + client, + objectNameSingular: 'resendBroadcast', + objectNamePlural: 'resendBroadcasts', + }); + + aggregate.fetched += pageOutcome.result.fetched; + aggregate.created += pageOutcome.result.created; + aggregate.updated += pageOutcome.result.updated; + aggregate.errors.push(...pageOutcome.result.errors); + + return { ok: pageOutcome.ok, errors: pageOutcome.result.errors }; + }, + 'broadcasts', + { + startCursor: resumeCursor, + onCursorAdvance, + ...(isDefined(options?.deadlineAtMs) && { + deadlineAtMs: options.deadlineAtMs, + }), + }, + ); + + return { value: undefined, completed }; + }, ); - const existingMap = await getExistingRecordsMap(client, 'resendBroadcasts'); - - const result = await upsertRecords({ - items: broadcasts, - getId: (broadcast) => broadcast.id, - fetchDetail: async (id) => { - const { data: detail, error } = await resend.broadcasts.get(id); - - if (isDefined(error) || !isDefined(detail)) { - throw new Error( - `Failed to fetch broadcast ${id}: ${JSON.stringify(error)}`, - ); - } - - return detail; - }, - mapCreateData: (detail, broadcast): CreateBroadcastDto => { - const segmentId = isDefined(broadcast.segment_id) - ? segmentMap.get(broadcast.segment_id) - : undefined; - - const data: CreateBroadcastDto = { - name: detail.name, - subject: detail.subject, - fromAddress: toEmailsField(detail.from), - replyTo: toEmailsField(detail.reply_to), - previewText: detail.preview_text ?? '', - status: detail.status.toUpperCase(), - createdAt: toIsoString(detail.created_at), - scheduledAt: toIsoStringOrNull(detail.scheduled_at), - sentAt: toIsoStringOrNull(detail.sent_at), - }; - - if (isDefined(segmentId)) { - data.segmentId = segmentId; - } - - return data; - }, - mapUpdateData: (_detail, broadcast): UpdateBroadcastDto => { - const data: UpdateBroadcastDto = { - status: broadcast.status.toUpperCase(), - scheduledAt: toIsoStringOrNull(broadcast.scheduled_at), - sentAt: toIsoStringOrNull(broadcast.sent_at), - }; - - if (!isDefined(broadcast.segment_id)) { - data.segmentId = null; - } else { - const segmentId = segmentMap.get(broadcast.segment_id); - - if (isDefined(segmentId)) { - data.segmentId = segmentId; - } else { - console.warn( - `[sync] broadcast ${broadcast.id}: segment ${broadcast.segment_id} not found in lookup map; leaving segmentId untouched`, - ); - } - } - - return data; - }, - existingMap, - client, - objectNameSingular: 'resendBroadcast', - }); - - return { result, value: undefined }; + return { result: aggregate, value: undefined }; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-contacts.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-contacts.ts index 8e993af0d2..c5538fe662 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-contacts.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-contacts.ts @@ -1,79 +1,210 @@ import type { Resend } from 'resend'; import { CoreApiClient } from 'twenty-client-sdk/core'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; -import type { ContactDto } from 'src/modules/resend/sync/types/contact.dto'; -import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result'; -import { fetchAllPaginated } from 'src/modules/resend/shared/utils/fetch-all-paginated'; -import { findOrCreatePerson } from 'src/modules/resend/shared/utils/find-or-create-person'; -import { getErrorMessage } from 'src/modules/resend/shared/utils/get-error-message'; -import { getExistingRecordsMap } from 'src/modules/resend/sync/utils/get-existing-records-map'; -import { toEmailsField } from 'src/modules/resend/shared/utils/to-emails-field'; -import { toIsoString } from 'src/modules/resend/shared/utils/to-iso-string'; -import { upsertRecords } from 'src/modules/resend/sync/utils/upsert-records'; +import type { ContactDto } from '@modules/resend/sync/types/contact.dto'; +import type { SyncResult } from '@modules/resend/sync/types/sync-result'; +import type { SyncStepResult } from '@modules/resend/sync/types/sync-step-result'; +import { findPeopleByEmail } from '@modules/resend/shared/utils/find-people-by-email'; +import { findTwentyIdsByResendId } from '@modules/resend/shared/utils/find-twenty-ids-by-resend-id'; +import { forEachPage } from '@modules/resend/shared/utils/for-each-page'; +import { getErrorMessage } from '@modules/resend/shared/utils/get-error-message'; +import { toEmailsField } from '@modules/resend/shared/utils/to-emails-field'; +import { toIsoString } from '@modules/resend/shared/utils/to-iso-string'; +import { withRateLimitRetry } from '@modules/resend/shared/utils/with-rate-limit-retry'; +import { + backfillResendEmailsFromContacts, + type ContactBackfillEntry, +} from '@modules/resend/sync/utils/backfill-resend-emails-from-contacts'; +import { upsertRecords } from '@modules/resend/sync/utils/upsert-records'; +import { withSyncCursor } from '@modules/resend/sync/cursor/utils/with-sync-cursor'; + +type RawContact = { + id: string; + email: string; + first_name: string | null; + last_name: string | null; + unsubscribed: boolean; + created_at: string; +}; + +const toContactDto = ( + contact: RawContact, + syncedAt: string, + personId: string | undefined, + segmentId: string | undefined, +): ContactDto => ({ + email: toEmailsField(contact.email), + name: { + firstName: contact.first_name ?? '', + lastName: contact.last_name ?? '', + }, + unsubscribed: contact.unsubscribed, + createdAt: toIsoString(contact.created_at), + lastSyncedFromResend: syncedAt, + ...(isDefined(personId) && { personId }), + ...(isDefined(segmentId) && { segmentId }), +}); + +const fetchFirstSegmentResendIdsForPage = async ( + resend: Resend, + pageContacts: ReadonlyArray, + errors: string[], +): Promise> => { + const firstSegmentByContactId = new Map(); + + for (const contact of pageContacts) { + try { + const { data, error } = await withRateLimitRetry( + () => + resend.contacts.segments.list({ + contactId: contact.id, + limit: 1, + }), + { channel: 'contact-segments' }, + ); + + if (isDefined(error)) { + errors.push( + `resendContact ${contact.id} segments: ${JSON.stringify(error)}`, + ); + continue; + } + + const firstSegmentId = data?.data?.[0]?.id; + + if (typeof firstSegmentId === 'string' && firstSegmentId.length > 0) { + firstSegmentByContactId.set(contact.id, firstSegmentId); + } + } catch (error) { + errors.push( + `resendContact ${contact.id} segments: ${getErrorMessage(error)}`, + ); + } + } + + return firstSegmentByContactId; +}; + +export type SyncContactsOptions = { + deadlineAtMs?: number; +}; export const syncContacts = async ( resend: Resend, client: CoreApiClient, syncedAt: string, + options?: SyncContactsOptions, ): Promise => { - const contacts = await fetchAllPaginated( - (params) => resend.contacts.list(params), - 'contacts', - ); + const aggregate: SyncResult = { + fetched: 0, + created: 0, + updated: 0, + errors: [], + }; - const existingMap = await getExistingRecordsMap(client, 'resendContacts'); + await withSyncCursor(client, 'CONTACTS', async ({ resumeCursor, onCursorAdvance }) => { + const { completed } = await forEachPage( + (paginationParameters) => resend.contacts.list(paginationParameters), + async (pageContacts) => { + const firstSegmentByContactId = await fetchFirstSegmentResendIdsForPage( + resend, + pageContacts, + aggregate.errors, + ); - const mapData = (contact: (typeof contacts)[number]): ContactDto => ({ - email: toEmailsField(contact.email), - name: { - firstName: contact.first_name ?? '', - lastName: contact.last_name ?? '', - }, - unsubscribed: contact.unsubscribed, - createdAt: toIsoString(contact.created_at), - lastSyncedFromResend: syncedAt, - }); + const uniqueSegmentResendIds = Array.from( + new Set(firstSegmentByContactId.values()), + ); - const result = await upsertRecords({ - items: contacts, - getId: (contact) => contact.id, - mapCreateData: (_detail, item) => mapData(item), - mapUpdateData: (_detail, item) => mapData(item), - existingMap, - client, - objectNameSingular: 'resendContact', - }); + const [personIdByEmail, twentySegmentIdByResendId] = await Promise.all([ + findPeopleByEmail( + client, + pageContacts.map((contact) => contact.email), + ), + uniqueSegmentResendIds.length > 0 + ? findTwentyIdsByResendId( + client, + 'resendSegments', + uniqueSegmentResendIds, + ) + : Promise.resolve(new Map()), + ]); - for (const contact of contacts) { - const twentyId = existingMap.get(contact.id); + const resolvePersonId = (email: string): string | undefined => + personIdByEmail.get(email.trim().toLowerCase()); - if (!isDefined(twentyId)) { - continue; - } + const resolveSegmentId = (contactId: string): string | undefined => { + const segmentResendId = firstSegmentByContactId.get(contactId); - try { - const personId = await findOrCreatePerson(client, contact.email, { - firstName: contact.first_name ?? '', - lastName: contact.last_name ?? '', - }); + if (!isDefined(segmentResendId)) return undefined; - if (isDefined(personId)) { - await client.mutation({ - updateResendContact: { - __args: { id: twentyId, data: { personId } }, - id: true, - }, + return twentySegmentIdByResendId.get(segmentResendId); + }; + + const pageOutcome = await upsertRecords({ + items: pageContacts, + getId: (contact) => contact.id, + mapCreateData: (_detail, item) => + toContactDto( + item, + syncedAt, + resolvePersonId(item.email), + resolveSegmentId(item.id), + ), + mapUpdateData: (_detail, item) => + toContactDto( + item, + syncedAt, + resolvePersonId(item.email), + resolveSegmentId(item.id), + ), + client, + objectNameSingular: 'resendContact', + objectNamePlural: 'resendContacts', }); - } - } catch (error) { - const message = getErrorMessage(error); - result.errors.push( - `resendContact ${contact.id} person link: ${message}`, - ); - } - } + aggregate.fetched += pageOutcome.result.fetched; + aggregate.created += pageOutcome.result.created; + aggregate.updated += pageOutcome.result.updated; + aggregate.errors.push(...pageOutcome.result.errors); - return { result, value: undefined }; + const entriesByEmail = new Map(); + + for (const contact of pageContacts) { + const twentyContactId = pageOutcome.twentyIdByResendId.get(contact.id); + + if (!isDefined(twentyContactId)) continue; + + const personId = resolvePersonId(contact.email); + + entriesByEmail.set(contact.email.trim().toLowerCase(), { + contactId: twentyContactId, + ...(isDefined(personId) && { personId }), + }); + } + + const emailBackfill = await backfillResendEmailsFromContacts( + client, + entriesByEmail, + ); + + aggregate.errors.push(...emailBackfill.errors); + + return { ok: pageOutcome.ok, errors: pageOutcome.result.errors }; + }, + 'contacts', + { + startCursor: resumeCursor, + onCursorAdvance, + ...(isDefined(options?.deadlineAtMs) && { + deadlineAtMs: options.deadlineAtMs, + }), + }, + ); + + return { value: undefined, completed }; + }); + + return { result: aggregate, value: undefined }; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-emails.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-emails.ts index 0254f0c436..c16d1b1280 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-emails.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-emails.ts @@ -1,113 +1,215 @@ +import { isDefined } from '@utils/is-defined'; import type { Resend } from 'resend'; import { CoreApiClient } from 'twenty-client-sdk/core'; -import { isDefined } from 'twenty-shared/utils'; -import type { CreateEmailDto } from 'src/modules/resend/sync/types/create-email.dto'; -import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result'; -import type { UpdateEmailDto } from 'src/modules/resend/sync/types/update-email.dto'; -import { fetchAllPaginated } from 'src/modules/resend/shared/utils/fetch-all-paginated'; -import { findOrCreatePerson } from 'src/modules/resend/shared/utils/find-or-create-person'; -import { getErrorMessage } from 'src/modules/resend/shared/utils/get-error-message'; -import { getExistingRecordsMap } from 'src/modules/resend/sync/utils/get-existing-records-map'; -import { mapLastEvent } from 'src/modules/resend/shared/utils/map-last-event'; -import { toEmailsField } from 'src/modules/resend/shared/utils/to-emails-field'; +import { findPeopleByEmail } from '@modules/resend/shared/utils/find-people-by-email'; +import { findResendContactsByEmail } from '@modules/resend/shared/utils/find-resend-contacts-by-email'; +import { forEachPage } from '@modules/resend/shared/utils/for-each-page'; +import { mapLastEvent } from '@modules/resend/shared/utils/map-last-event'; +import { toEmailsField } from '@modules/resend/shared/utils/to-emails-field'; import { toIsoString, toIsoStringOrNull, -} from 'src/modules/resend/shared/utils/to-iso-string'; -import { upsertRecords } from 'src/modules/resend/sync/utils/upsert-records'; +} from '@modules/resend/shared/utils/to-iso-string'; +import { withSyncCursor } from '@modules/resend/sync/cursor/utils/with-sync-cursor'; +import type { CreateEmailDto } from '@modules/resend/sync/types/create-email.dto'; +import type { SyncResult } from '@modules/resend/sync/types/sync-result'; +import type { SyncStepResult } from '@modules/resend/sync/types/sync-step-result'; +import type { UpdateEmailDto } from '@modules/resend/sync/types/update-email.dto'; +import { backfillResendContactPersonId } from '@modules/resend/sync/utils/backfill-resend-contact-person-id'; +import { findRecentSentBroadcasts } from '@modules/resend/sync/utils/find-recent-sent-broadcasts'; +import { + BROADCAST_EMAIL_MATCH_WINDOW_MS, + resolveBroadcastIdForEmail, +} from '@modules/resend/sync/utils/resolve-broadcast-id-for-email'; +import { upsertRecords } from '@modules/resend/sync/utils/upsert-records'; + +export type SyncEmailsOptions = { + stopBeforeCreatedAtMs?: number; + resumable?: boolean; + deadlineAtMs?: number; +}; export const syncEmails = async ( resend: Resend, client: CoreApiClient, syncedAt: string, + options?: SyncEmailsOptions, ): Promise => { - const emails = await fetchAllPaginated( - (params) => resend.emails.list(params), - 'emails', - ); + const aggregate: SyncResult = { + fetched: 0, + created: 0, + updated: 0, + errors: [], + }; - const existingMap = await getExistingRecordsMap(client, 'resendEmails'); + const resumable = options?.resumable ?? true; + const stopBeforeCreatedAtMs = options?.stopBeforeCreatedAtMs; + const cutoffTimestampMs = isDefined(stopBeforeCreatedAtMs) + ? Date.now() - stopBeforeCreatedAtMs + : undefined; - const result = await upsertRecords({ - items: emails, - getId: (email) => email.id, - fetchDetail: async (id) => { - const { data: detail, error } = await resend.emails.get(id); - - if (isDefined(error) || !isDefined(detail)) { - throw new Error( - `Failed to fetch email ${id}: ${JSON.stringify(error)}`, - ); - } - - return detail; - }, - mapCreateData: (detail): CreateEmailDto => { - const mappedLastEvent = mapLastEvent(detail.last_event); - - return { - subject: detail.subject, - fromAddress: toEmailsField(detail.from), - toAddresses: toEmailsField(detail.to), - htmlBody: detail.html ?? '', - textBody: detail.text ?? '', - ccAddresses: toEmailsField(detail.cc), - bccAddresses: toEmailsField(detail.bcc), - replyToAddresses: toEmailsField(detail.reply_to), - ...(isDefined(mappedLastEvent) && { lastEvent: mappedLastEvent }), - createdAt: toIsoString(detail.created_at), - scheduledAt: toIsoStringOrNull(detail.scheduled_at), - tags: detail.tags, - lastSyncedFromResend: syncedAt, - }; - }, - mapUpdateData: (_detail, email): UpdateEmailDto => { - const mappedLastEvent = mapLastEvent(email.last_event); - - return { - subject: email.subject, - fromAddress: toEmailsField(email.from), - toAddresses: toEmailsField(email.to), - ccAddresses: toEmailsField(email.cc), - bccAddresses: toEmailsField(email.bcc), - replyToAddresses: toEmailsField(email.reply_to), - ...(isDefined(mappedLastEvent) && { lastEvent: mappedLastEvent }), - scheduledAt: toIsoStringOrNull(email.scheduled_at), - lastSyncedFromResend: syncedAt, - }; - }, - existingMap, - client, - objectNameSingular: 'resendEmail', + const broadcastSinceIso = isDefined(cutoffTimestampMs) + ? new Date(cutoffTimestampMs - BROADCAST_EMAIL_MATCH_WINDOW_MS).toISOString() + : new Date(0).toISOString(); + const sortedBroadcasts = await findRecentSentBroadcasts(client, { + sinceIso: broadcastSinceIso, }); - for (const email of emails) { - const twentyId = existingMap.get(email.id); + await withSyncCursor( + client, + 'EMAILS', + async ({ resumeCursor, onCursorAdvance }) => { + const { completed } = await forEachPage( + (paginationParameters) => resend.emails.list(paginationParameters), + async (pageEmails) => { + const primaryToByEmail = new Map(); - if (!isDefined(twentyId)) { - continue; - } + for (const email of pageEmails) { + const primaryTo = Array.isArray(email.to) ? email.to[0] : email.to; - const primaryTo = Array.isArray(email.to) ? email.to[0] : email.to; + if (typeof primaryTo === 'string' && primaryTo.length > 0) { + primaryToByEmail.set(email.id, primaryTo); + } + } - try { - const personId = await findOrCreatePerson(client, primaryTo); + const primaryEmails = Array.from(primaryToByEmail.values()); - if (isDefined(personId)) { - await client.mutation({ - updateResendEmail: { - __args: { id: twentyId, data: { personId } }, - id: true, - }, - }); - } - } catch (error) { - const message = getErrorMessage(error); + const [personIdByEmail, contactByEmail] = await Promise.all([ + findPeopleByEmail(client, primaryEmails), + findResendContactsByEmail(client, primaryEmails), + ]); - result.errors.push(`resendEmail ${email.id} person link: ${message}`); - } - } + const resolvePersonId = (resendEmailId: string): string | undefined => { + const primaryTo = primaryToByEmail.get(resendEmailId); - return { result, value: undefined }; + if (!isDefined(primaryTo)) return undefined; + + return personIdByEmail.get(primaryTo.trim().toLowerCase()); + }; + + const resolveContactId = ( + resendEmailId: string, + ): string | undefined => { + const primaryTo = primaryToByEmail.get(resendEmailId); + + if (!isDefined(primaryTo)) return undefined; + + return contactByEmail.get(primaryTo.trim().toLowerCase())?.id; + }; + + const resolveBroadcastId = (createdAt: string): string | undefined => + resolveBroadcastIdForEmail( + new Date(createdAt).getTime(), + sortedBroadcasts, + ); + + const pageOutcome = await upsertRecords({ + items: pageEmails, + getId: (email) => email.id, + mapCreateData: (_detail, email): CreateEmailDto => { + const mappedLastEvent = mapLastEvent(email.last_event); + const personId = resolvePersonId(email.id); + const contactId = resolveContactId(email.id); + const broadcastId = resolveBroadcastId(email.created_at); + + return { + subject: email.subject, + fromAddress: toEmailsField(email.from), + toAddresses: toEmailsField(email.to), + ccAddresses: toEmailsField(email.cc), + bccAddresses: toEmailsField(email.bcc), + replyToAddresses: toEmailsField(email.reply_to), + ...(isDefined(mappedLastEvent) && { + lastEvent: mappedLastEvent, + }), + createdAt: toIsoString(email.created_at), + scheduledAt: toIsoStringOrNull(email.scheduled_at), + lastSyncedFromResend: syncedAt, + ...(isDefined(personId) && { personId }), + ...(isDefined(contactId) && { contactId }), + ...(isDefined(broadcastId) && { broadcastId }), + }; + }, + mapUpdateData: (_detail, email): UpdateEmailDto => { + const mappedLastEvent = mapLastEvent(email.last_event); + const personId = resolvePersonId(email.id); + const contactId = resolveContactId(email.id); + const broadcastId = resolveBroadcastId(email.created_at); + + return { + subject: email.subject, + fromAddress: toEmailsField(email.from), + toAddresses: toEmailsField(email.to), + ccAddresses: toEmailsField(email.cc), + bccAddresses: toEmailsField(email.bcc), + replyToAddresses: toEmailsField(email.reply_to), + ...(isDefined(mappedLastEvent) && { + lastEvent: mappedLastEvent, + }), + scheduledAt: toIsoStringOrNull(email.scheduled_at), + lastSyncedFromResend: syncedAt, + ...(isDefined(personId) && { personId }), + ...(isDefined(contactId) && { contactId }), + ...(isDefined(broadcastId) && { broadcastId }), + }; + }, + client, + objectNameSingular: 'resendEmail', + objectNamePlural: 'resendEmails', + }); + + aggregate.fetched += pageOutcome.result.fetched; + aggregate.created += pageOutcome.result.created; + aggregate.updated += pageOutcome.result.updated; + aggregate.errors.push(...pageOutcome.result.errors); + + const personBackfillForContacts = new Map(); + + for (const [normalizedEmail, contact] of contactByEmail) { + if (isDefined(contact.personId)) continue; + + const personId = personIdByEmail.get(normalizedEmail); + + if (isDefined(personId)) { + personBackfillForContacts.set(normalizedEmail, personId); + } + } + + const contactBackfill = await backfillResendContactPersonId( + client, + personBackfillForContacts, + ); + + aggregate.errors.push(...contactBackfill.errors); + + const reachedCutoff = + isDefined(cutoffTimestampMs) && + pageEmails.some( + (email) => + new Date(email.created_at).getTime() < cutoffTimestampMs, + ); + + return { + ok: pageOutcome.ok, + stop: reachedCutoff, + errors: pageOutcome.result.errors, + }; + }, + 'emails', + { + startCursor: resumable ? resumeCursor : undefined, + ...(resumable && { onCursorAdvance }), + ...(isDefined(options?.deadlineAtMs) && { + deadlineAtMs: options.deadlineAtMs, + }), + }, + ); + + return { value: undefined, completed: resumable ? completed : true }; + }, + { preserveCursor: !resumable }, + ); + + return { result: aggregate, value: undefined }; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-segments.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-segments.ts index 7586f7c649..aecc3c79d6 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-segments.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-segments.ts @@ -1,42 +1,85 @@ import type { Resend } from 'resend'; import { CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from '@utils/is-defined'; -import type { SegmentDto } from 'src/modules/resend/sync/types/segment.dto'; -import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result'; -import { fetchAllPaginated } from 'src/modules/resend/shared/utils/fetch-all-paginated'; -import { getExistingRecordsMap } from 'src/modules/resend/sync/utils/get-existing-records-map'; -import { toIsoString } from 'src/modules/resend/shared/utils/to-iso-string'; -import { upsertRecords } from 'src/modules/resend/sync/utils/upsert-records'; +import type { SegmentDto } from '@modules/resend/sync/types/segment.dto'; +import type { SyncResult } from '@modules/resend/sync/types/sync-result'; +import type { SyncStepResult } from '@modules/resend/sync/types/sync-step-result'; +import { forEachPage } from '@modules/resend/shared/utils/for-each-page'; +import { toIsoString } from '@modules/resend/shared/utils/to-iso-string'; +import { upsertRecords } from '@modules/resend/sync/utils/upsert-records'; +import { withSyncCursor } from '@modules/resend/sync/cursor/utils/with-sync-cursor'; export type SegmentIdMap = Map; +type RawSegment = { + id: string; + name: string; + created_at: string; +}; + +const toSegmentDto = (segment: RawSegment, syncedAt: string): SegmentDto => ({ + name: segment.name, + createdAt: toIsoString(segment.created_at), + lastSyncedFromResend: syncedAt, +}); + +export type SyncSegmentsOptions = { + deadlineAtMs?: number; +}; + export const syncSegments = async ( resend: Resend, client: CoreApiClient, syncedAt: string, + options?: SyncSegmentsOptions, ): Promise> => { - const segments = await fetchAllPaginated( - (params) => resend.segments.list(params), - 'segments', - ); + const aggregate: SyncResult = { + fetched: 0, + created: 0, + updated: 0, + errors: [], + }; - const existingMap = await getExistingRecordsMap(client, 'resendSegments'); + const segmentIdMap: SegmentIdMap = new Map(); - const mapData = (segment: (typeof segments)[number]): SegmentDto => ({ - name: segment.name, - createdAt: toIsoString(segment.created_at), - lastSyncedFromResend: syncedAt, + await withSyncCursor(client, 'SEGMENTS', async ({ resumeCursor, onCursorAdvance }) => { + const { completed } = await forEachPage( + (paginationParameters) => resend.segments.list(paginationParameters), + async (pageSegments) => { + const pageOutcome = await upsertRecords({ + items: pageSegments, + getId: (segment) => segment.id, + mapCreateData: (_detail, item) => toSegmentDto(item, syncedAt), + mapUpdateData: (_detail, item) => toSegmentDto(item, syncedAt), + client, + objectNameSingular: 'resendSegment', + objectNamePlural: 'resendSegments', + }); + + aggregate.fetched += pageOutcome.result.fetched; + aggregate.created += pageOutcome.result.created; + aggregate.updated += pageOutcome.result.updated; + aggregate.errors.push(...pageOutcome.result.errors); + + for (const [resendId, twentyId] of pageOutcome.twentyIdByResendId) { + segmentIdMap.set(resendId, twentyId); + } + + return { ok: pageOutcome.ok, errors: pageOutcome.result.errors }; + }, + 'segments', + { + startCursor: resumeCursor, + onCursorAdvance, + ...(isDefined(options?.deadlineAtMs) && { + deadlineAtMs: options.deadlineAtMs, + }), + }, + ); + + return { value: undefined, completed }; }); - const result = await upsertRecords({ - items: segments, - getId: (segment) => segment.id, - mapCreateData: (_detail, item) => mapData(item), - mapUpdateData: (_detail, item) => mapData(item), - existingMap, - client, - objectNameSingular: 'resendSegment', - }); - - return { result, value: existingMap }; + return { result: aggregate, value: segmentIdMap }; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-templates.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-templates.ts index a8b512a1f6..62cd15bc2b 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-templates.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-templates.ts @@ -1,90 +1,152 @@ import type { Resend } from 'resend'; import { CoreApiClient } from 'twenty-client-sdk/core'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; -import type { CreateTemplateDto } from 'src/modules/resend/sync/types/create-template.dto'; -import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result'; -import type { UpdateTemplateDto } from 'src/modules/resend/sync/types/update-template.dto'; -import { fetchAllPaginated } from 'src/modules/resend/shared/utils/fetch-all-paginated'; -import { getExistingRecordsMap } from 'src/modules/resend/sync/utils/get-existing-records-map'; -import { toEmailsField } from 'src/modules/resend/shared/utils/to-emails-field'; +import { forEachPage } from '@modules/resend/shared/utils/for-each-page'; +import { getErrorMessage } from '@modules/resend/shared/utils/get-error-message'; +import { toEmailsField } from '@modules/resend/shared/utils/to-emails-field'; import { toIsoString, toIsoStringOrNull, -} from 'src/modules/resend/shared/utils/to-iso-string'; -import { upsertRecords } from 'src/modules/resend/sync/utils/upsert-records'; -import { withRateLimitRetry } from 'src/modules/resend/shared/utils/with-rate-limit-retry'; +} from '@modules/resend/shared/utils/to-iso-string'; +import { withRateLimitRetry } from '@modules/resend/shared/utils/with-rate-limit-retry'; +import { withSyncCursor } from '@modules/resend/sync/cursor/utils/with-sync-cursor'; +import type { CreateTemplateDto } from '@modules/resend/sync/types/create-template.dto'; +import type { SyncResult } from '@modules/resend/sync/types/sync-result'; +import type { SyncStepResult } from '@modules/resend/sync/types/sync-step-result'; +import type { UpdateTemplateDto } from '@modules/resend/sync/types/update-template.dto'; +import { upsertRecords } from '@modules/resend/sync/utils/upsert-records'; + +type TemplateDetail = Awaited< + ReturnType +>['data']; + +const fetchTemplateDetailsForPage = async ( + resend: Resend, + pageTemplates: ReadonlyArray<{ id: string }>, + errors: string[], +): Promise>> => { + const detailByResendId = new Map>(); + + for (const template of pageTemplates) { + try { + const { data: detail, error } = await withRateLimitRetry( + () => resend.templates.get(template.id), + { channel: 'templates-detail' }, + ); + + if (isDefined(error) || !isDefined(detail)) { + errors.push( + `resendTemplate ${template.id} detail: ${JSON.stringify(error)}`, + ); + continue; + } + + detailByResendId.set(template.id, detail); + } catch (error) { + errors.push( + `resendTemplate ${template.id} detail: ${getErrorMessage(error)}`, + ); + } + } + + return detailByResendId; +}; + +export type SyncTemplatesOptions = { + deadlineAtMs?: number; +}; export const syncTemplates = async ( resend: Resend, client: CoreApiClient, + options?: SyncTemplatesOptions, ): Promise => { - const templates = await fetchAllPaginated( - (params) => resend.templates.list(params), - 'templates', - ); - const existingMap = await getExistingRecordsMap(client, 'resendTemplates'); + const aggregate: SyncResult = { + fetched: 0, + created: 0, + updated: 0, + errors: [], + }; - const detailsMap = new Map< - string, - NonNullable>['data']> - >(); - - for (const template of templates) { - const { data: detail, error } = await withRateLimitRetry(() => - resend.templates.get(template.id), - ); - - if (isDefined(error) || !isDefined(detail)) { - throw new Error( - `Failed to fetch template ${template.id}: ${JSON.stringify(error)}`, - ); - } - - detailsMap.set(template.id, detail); - } - - const result = await upsertRecords({ - items: templates, - getId: (template) => template.id, - fetchDetail: async (id) => { - const detail = detailsMap.get(id); - - if (!isDefined(detail)) { - throw new Error(`Template detail for ${id} not found in cache`); - } - - return detail; - }, - mapCreateData: (detail): CreateTemplateDto => ({ - name: detail.name, - alias: detail.alias ?? '', - status: detail.status.toUpperCase(), - fromAddress: toEmailsField(detail.from), - subject: detail.subject ?? '', - replyTo: toEmailsField(detail.reply_to), - htmlBody: detail.html ?? '', - textBody: detail.text ?? '', - createdAt: toIsoString(detail.created_at), - resendUpdatedAt: toIsoString(detail.updated_at), - publishedAt: toIsoStringOrNull(detail.published_at), - }), - mapUpdateData: (detail, template): UpdateTemplateDto => ({ - name: template.name, - alias: template.alias ?? '', - status: template.status.toUpperCase(), - fromAddress: toEmailsField(detail.from), - subject: detail.subject ?? '', - replyTo: toEmailsField(detail.reply_to), - htmlBody: detail.html ?? '', - textBody: detail.text ?? '', - resendUpdatedAt: toIsoString(template.updated_at), - publishedAt: toIsoStringOrNull(template.published_at), - }), - existingMap, + await withSyncCursor( client, - objectNameSingular: 'resendTemplate', - }); + 'TEMPLATES', + async ({ resumeCursor, onCursorAdvance }) => { + const { completed } = await forEachPage( + (paginationParameters) => resend.templates.list(paginationParameters), + async (pageTemplates) => { + const detailByResendId = await fetchTemplateDetailsForPage( + resend, + pageTemplates, + aggregate.errors, + ); - return { result, value: undefined }; + const pageOutcome = await upsertRecords({ + items: pageTemplates, + getId: (template) => template.id, + mapCreateData: (_detail, template): CreateTemplateDto => { + const detail = detailByResendId.get(template.id); + + return { + name: template.name, + alias: template.alias ?? '', + status: template.status.toUpperCase(), + createdAt: toIsoString(template.created_at), + resendUpdatedAt: toIsoString(template.updated_at), + publishedAt: toIsoStringOrNull(template.published_at), + ...(isDefined(detail) && { + fromAddress: toEmailsField(detail.from), + subject: detail.subject ?? '', + replyTo: toEmailsField(detail.reply_to), + htmlBody: detail.html ?? '', + textBody: detail.text ?? '', + }), + }; + }, + mapUpdateData: (_detail, template): UpdateTemplateDto => { + const detail = detailByResendId.get(template.id); + + return { + name: template.name, + alias: template.alias ?? '', + status: template.status.toUpperCase(), + resendUpdatedAt: toIsoString(template.updated_at), + publishedAt: toIsoStringOrNull(template.published_at), + ...(isDefined(detail) && { + fromAddress: toEmailsField(detail.from), + subject: detail.subject ?? '', + replyTo: toEmailsField(detail.reply_to), + htmlBody: detail.html ?? '', + textBody: detail.text ?? '', + }), + }; + }, + client, + objectNameSingular: 'resendTemplate', + objectNamePlural: 'resendTemplates', + }); + + aggregate.fetched += pageOutcome.result.fetched; + aggregate.created += pageOutcome.result.created; + aggregate.updated += pageOutcome.result.updated; + aggregate.errors.push(...pageOutcome.result.errors); + + return { ok: pageOutcome.ok, errors: pageOutcome.result.errors }; + }, + 'templates', + { + startCursor: resumeCursor, + onCursorAdvance, + ...(isDefined(options?.deadlineAtMs) && { + deadlineAtMs: options.deadlineAtMs, + }), + }, + ); + + return { value: undefined, completed }; + }, + ); + + return { result: aggregate, value: undefined }; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-topics.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-topics.ts new file mode 100644 index 0000000000..3f799cc710 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/sync-topics.ts @@ -0,0 +1,105 @@ +import type { Resend } from 'resend'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from '@utils/is-defined'; + +import { withRateLimitRetry } from '@modules/resend/shared/utils/with-rate-limit-retry'; +import { withSyncCursor } from '@modules/resend/sync/cursor/utils/with-sync-cursor'; +import type { SyncResult } from '@modules/resend/sync/types/sync-result'; +import type { SyncStepResult } from '@modules/resend/sync/types/sync-step-result'; +import type { TopicDto } from '@modules/resend/sync/types/topic.dto'; +import { toIsoString } from '@modules/resend/shared/utils/to-iso-string'; +import { upsertRecords } from '@modules/resend/sync/utils/upsert-records'; + +export type TopicIdMap = Map; + +type RawTopic = { + id: string; + name: string; + description?: string | null; + default_subscription: string; + visibility?: string; + created_at: string; +}; + +const toTopicDto = (topic: RawTopic, syncedAt: string): TopicDto => ({ + name: topic.name, + description: topic.description ?? '', + defaultSubscription: topic.default_subscription.toUpperCase(), + visibility: (topic.visibility ?? 'public').toUpperCase(), + createdAt: toIsoString(topic.created_at), + lastSyncedFromResend: syncedAt, +}); + +export type SyncTopicsOptions = { + deadlineAtMs?: number; +}; + +export const syncTopics = async ( + resend: Resend, + client: CoreApiClient, + syncedAt: string, + _options?: SyncTopicsOptions, +): Promise> => { + const aggregate: SyncResult = { + fetched: 0, + created: 0, + updated: 0, + errors: [], + }; + + const topicIdMap: TopicIdMap = new Map(); + + await withSyncCursor(client, 'TOPICS', async () => { + const response = await withRateLimitRetry(() => resend.topics.list(), { + channel: 'topics', + }); + + if (isDefined(response.error)) { + throw new Error( + `Resend list[topics] failed: ${JSON.stringify(response.error)}`, + ); + } + + const topics = (response.data?.data ?? []) as RawTopic[]; + + if (topics.length === 0) { + return { value: undefined, completed: true }; + } + + console.log(`[resend] fetched topics page 1 (size=${topics.length})`); + + const pageOutcome = await upsertRecords({ + items: topics, + getId: (topic) => topic.id, + mapCreateData: (_detail, item) => toTopicDto(item, syncedAt), + mapUpdateData: (_detail, item) => toTopicDto(item, syncedAt), + client, + objectNameSingular: 'resendTopic', + objectNamePlural: 'resendTopics', + }); + + aggregate.fetched += pageOutcome.result.fetched; + aggregate.created += pageOutcome.result.created; + aggregate.updated += pageOutcome.result.updated; + aggregate.errors.push(...pageOutcome.result.errors); + + for (const [resendId, twentyId] of pageOutcome.twentyIdByResendId) { + topicIdMap.set(resendId, twentyId); + } + + if (!pageOutcome.ok) { + const detail = + pageOutcome.result.errors.length > 0 + ? ` failures: ${pageOutcome.result.errors.join(' | ')}` + : ''; + + throw new Error( + `Resend topics page reported per-item failures; aborting.${detail}`, + ); + } + + return { value: undefined, completed: true }; + }); + + return { result: aggregate, value: topicIdMap }; +}; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/upsert-record.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/upsert-record.ts index ba4d1ec866..4df0339619 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/upsert-record.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/upsert-record.ts @@ -1,7 +1,8 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; -import { capitalize } from 'src/modules/resend/shared/utils/capitalize'; +import { capitalize } from '@modules/resend/shared/utils/capitalize'; +import { extractMutationRecord } from '@modules/resend/shared/utils/typed-client'; export const upsertRecord = async ( client: CoreApiClient, @@ -38,9 +39,10 @@ export const upsertRecord = async ( }, }); - const created = (createResult as Record)[ - createMutationName - ] as { id: string } | undefined; + const created = extractMutationRecord<{ id: string }>( + createResult, + createMutationName, + ); if (isDefined(created)) { existingMap.set(resendId, created.id); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/upsert-records.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/upsert-records.ts index 7891686a6f..3ac99adc7f 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/upsert-records.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/sync/utils/upsert-records.ts @@ -1,26 +1,30 @@ -import type { SyncResult } from 'src/modules/resend/sync/types/sync-result'; -import type { UpsertRecordsOptions } from 'src/modules/resend/sync/types/upsert-records-options'; -import { getErrorMessage } from 'src/modules/resend/shared/utils/get-error-message'; -import { upsertRecord } from 'src/modules/resend/sync/utils/upsert-record'; -import { withRateLimitRetry } from 'src/modules/resend/shared/utils/with-rate-limit-retry'; +import type { SyncResult } from '@modules/resend/sync/types/sync-result'; +import type { UpsertRecordsOptions } from '@modules/resend/sync/types/upsert-records-options'; +import { fetchExistingTwentyIdsByResendIds } from '@modules/resend/sync/utils/fetch-existing-twenty-ids'; +import { getErrorMessage } from '@modules/resend/shared/utils/get-error-message'; +import { upsertRecord } from '@modules/resend/sync/utils/upsert-record'; + +export type UpsertRecordsPageOutcome = { + result: SyncResult; + ok: boolean; + twentyIdByResendId: Map; +}; export const upsertRecords = async < TListItem, - TDetail = TListItem, TCreateDto extends Record = Record, TUpdateDto extends Record = Record, >( - options: UpsertRecordsOptions, -): Promise => { + options: UpsertRecordsOptions, +): Promise => { const { items, getId, - fetchDetail, mapCreateData, mapUpdateData, - existingMap, client, objectNameSingular, + objectNamePlural, } = options; const result: SyncResult = { @@ -30,32 +34,36 @@ export const upsertRecords = async < errors: [], }; + const resendIds = items.map(getId); + + const twentyIdByResendId = await fetchExistingTwentyIdsByResendIds( + client, + objectNamePlural, + resendIds, + ); + for (const item of items) { const resendId = getId(item); try { - const isNew = !existingMap.has(resendId); - - const detail = fetchDetail - ? await withRateLimitRetry(() => fetchDetail(resendId)) - : (item as unknown as TDetail); + const isNew = !twentyIdByResendId.has(resendId); if (isNew) { - const data = mapCreateData(detail, item); + const data = mapCreateData(item, item); await upsertRecord( client, objectNameSingular, - existingMap, + twentyIdByResendId, resendId, data, ); result.created++; } else { - const data = mapUpdateData(detail, item); + const data = mapUpdateData(item, item); await upsertRecord( client, objectNameSingular, - existingMap, + twentyIdByResendId, resendId, data, ); @@ -68,5 +76,9 @@ export const upsertRecords = async < } } - return result; + return { + result, + ok: result.errors.length === 0, + twentyIdByResendId, + }; }; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/webhooks/logic-functions/__tests__/resend-webhook.test.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/webhooks/logic-functions/__tests__/resend-webhook.test.ts new file mode 100644 index 0000000000..ce7145bf56 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/webhooks/logic-functions/__tests__/resend-webhook.test.ts @@ -0,0 +1,143 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@modules/resend/shared/utils/find-or-create-person', () => ({ + findOrCreatePerson: vi.fn(), +})); + +vi.mock('@modules/resend/shared/utils/find-record-by-resend-id', () => ({ + findRecordByResendId: vi.fn(), +})); + +vi.mock('@modules/resend/shared/utils/find-twenty-ids-by-resend-id', () => ({ + findTwentyIdsByResendId: vi.fn(), +})); + +import { findOrCreatePerson } from '@modules/resend/shared/utils/find-or-create-person'; +import { findRecordByResendId } from '@modules/resend/shared/utils/find-record-by-resend-id'; +import { findTwentyIdsByResendId } from '@modules/resend/shared/utils/find-twenty-ids-by-resend-id'; +import { handleContactCreatedOrUpdated } from '@modules/resend/webhooks/logic-functions/resend-webhook'; + +const mockFindOrCreatePerson = findOrCreatePerson as unknown as ReturnType< + typeof vi.fn +>; +const mockFindRecordByResendId = findRecordByResendId as unknown as ReturnType< + typeof vi.fn +>; +const mockFindTwentyIdsByResendId = + findTwentyIdsByResendId as unknown as ReturnType; + +type MutationMock = ReturnType; + +const buildClient = (mutation: MutationMock): CoreApiClient => + ({ + mutation, + }) as unknown as CoreApiClient; + +const baseContactEvent = { + id: 'resend-contact-1', + email: 'Foo@Example.com', + first_name: 'Foo', + last_name: 'Bar', + unsubscribed: false, + segment_ids: [] as string[], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', +}; + +describe('handleContactCreatedOrUpdated (webhook)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindOrCreatePerson.mockResolvedValue('twenty-person-1'); + }); + + it('resolves the first segment_ids entry to a Twenty segment id and inlines it on create', async () => { + mockFindRecordByResendId.mockResolvedValue(undefined); + mockFindTwentyIdsByResendId.mockResolvedValue( + new Map([['resend-segment-1', 'twenty-segment-1']]), + ); + + const mutation = vi.fn(async () => ({ + createResendContact: { id: 'twenty-contact-1' }, + })); + + const result = await handleContactCreatedOrUpdated(buildClient(mutation), { + ...baseContactEvent, + segment_ids: ['resend-segment-1', 'resend-segment-2'], + }); + + expect(mockFindTwentyIdsByResendId).toHaveBeenCalledWith( + expect.anything(), + 'resendSegments', + ['resend-segment-1'], + ); + + expect(mutation).toHaveBeenCalledTimes(1); + + const args = ( + mutation.mock.calls[0] as unknown as Array<{ + createResendContact: { __args: { data: Record } }; + }> + )[0].createResendContact.__args; + + expect(args.data.segmentId).toBe('twenty-segment-1'); + expect(args.data.personId).toBe('twenty-person-1'); + expect(args.data.resendId).toBe('resend-contact-1'); + expect(args.data.email).toEqual({ + primaryEmail: 'foo@example.com', + additionalEmails: null, + }); + + expect(result).toEqual({ + action: 'created', + twentyId: 'twenty-contact-1', + resendId: 'resend-contact-1', + personId: 'twenty-person-1', + }); + }); + + it('omits segmentId when the first segment is not yet known to Twenty', async () => { + mockFindRecordByResendId.mockResolvedValue(undefined); + mockFindTwentyIdsByResendId.mockResolvedValue(new Map()); + + const mutation = vi.fn(async () => ({ + createResendContact: { id: 'twenty-contact-1' }, + })); + + await handleContactCreatedOrUpdated(buildClient(mutation), { + ...baseContactEvent, + segment_ids: ['unknown-segment'], + }); + + const args = ( + mutation.mock.calls[0] as unknown as Array<{ + createResendContact: { __args: { data: Record } }; + }> + )[0].createResendContact.__args; + + expect(args.data.segmentId).toBeUndefined(); + }); + + it('does not look up segments when segment_ids is empty and inlines segmentId on update', async () => { + mockFindRecordByResendId.mockResolvedValue('twenty-contact-existing'); + + const mutation = vi.fn(async () => ({ + updateResendContact: { id: 'twenty-contact-existing' }, + })); + + await handleContactCreatedOrUpdated(buildClient(mutation), { + ...baseContactEvent, + segment_ids: [], + }); + + expect(mockFindTwentyIdsByResendId).not.toHaveBeenCalled(); + + const args = ( + mutation.mock.calls[0] as unknown as Array<{ + updateResendContact: { __args: { data: Record } }; + }> + )[0].updateResendContact.__args; + + expect(args.data.segmentId).toBeUndefined(); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/webhooks/logic-functions/resend-webhook.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/webhooks/logic-functions/resend-webhook.ts index 4d4b39b8d1..7701e296bc 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/webhooks/logic-functions/resend-webhook.ts +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/modules/resend/webhooks/logic-functions/resend-webhook.ts @@ -1,15 +1,16 @@ import { isNonEmptyString } from '@sniptt/guards'; import { CoreApiClient } from 'twenty-client-sdk/core'; import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined } from '@utils/is-defined'; -import { RESEND_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers'; -import type { WebhookHandlerResult } from 'src/modules/resend/webhooks/types/webhook-handler-result'; -import { findOrCreatePerson } from 'src/modules/resend/shared/utils/find-or-create-person'; -import { findRecordByResendId } from 'src/modules/resend/shared/utils/find-record-by-resend-id'; -import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client'; -import { mapLastEvent } from 'src/modules/resend/shared/utils/map-last-event'; -import { toEmailsField } from 'src/modules/resend/shared/utils/to-emails-field'; +import { RESEND_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers'; +import type { WebhookHandlerResult } from '@modules/resend/webhooks/types/webhook-handler-result'; +import { findOrCreatePerson } from '@modules/resend/shared/utils/find-or-create-person'; +import { findRecordByResendId } from '@modules/resend/shared/utils/find-record-by-resend-id'; +import { findTwentyIdsByResendId } from '@modules/resend/shared/utils/find-twenty-ids-by-resend-id'; +import { getResendClient } from '@modules/resend/shared/utils/get-resend-client'; +import { mapLastEvent } from '@modules/resend/shared/utils/map-last-event'; +import { toEmailsField } from '@modules/resend/shared/utils/to-emails-field'; type ContactEventData = { id: string; @@ -38,7 +39,7 @@ type WebhookPayload = { data: ContactEventData | BaseEmailEventData | Record; }; -const handleContactCreatedOrUpdated = async ( +export const handleContactCreatedOrUpdated = async ( client: CoreApiClient, data: ContactEventData, ): Promise => { @@ -53,6 +54,24 @@ const handleContactCreatedOrUpdated = async ( lastName: data.last_name ?? '', }); + const firstSegmentResendId = Array.isArray(data.segment_ids) + ? data.segment_ids.find( + (id): id is string => typeof id === 'string' && id.length > 0, + ) + : undefined; + + let segmentId: string | undefined; + + if (isDefined(firstSegmentResendId)) { + const segmentIdMap = await findTwentyIdsByResendId( + client, + 'resendSegments', + [firstSegmentResendId], + ); + + segmentId = segmentIdMap.get(firstSegmentResendId); + } + const contactData: Record = { email: toEmailsField(data.email), name: { @@ -62,6 +81,7 @@ const handleContactCreatedOrUpdated = async ( unsubscribed: data.unsubscribed, lastSyncedFromResend: new Date().toISOString(), ...(isDefined(personId) && { personId }), + ...(isDefined(segmentId) && { segmentId }), }; if (isDefined(existingId)) { @@ -169,7 +189,7 @@ const handleEmailEvent = async ( }; const handler = async ( - params: RoutePayload, + routePayload: RoutePayload, ): Promise => { const webhookSecret = process.env.RESEND_WEBHOOK_SECRET; @@ -177,9 +197,9 @@ const handler = async ( throw new Error('RESEND_WEBHOOK_SECRET environment variable is not set'); } - const svixId = params.headers['svix-id']; - const svixTimestamp = params.headers['svix-timestamp']; - const svixSignature = params.headers['svix-signature']; + const svixId = routePayload.headers['svix-id']; + const svixTimestamp = routePayload.headers['svix-timestamp']; + const svixSignature = routePayload.headers['svix-signature']; if ( !isDefined(svixId) || @@ -189,12 +209,12 @@ const handler = async ( return { error: 'Missing webhook signature headers' }; } - const resend = getResendClient(); + const resendClient = getResendClient(); let event; try { - event = resend.webhooks.verify({ - payload: JSON.stringify(params.body), + event = resendClient.webhooks.verify({ + payload: JSON.stringify(routePayload.body), headers: { id: svixId, timestamp: svixTimestamp, diff --git a/packages/twenty-apps/internal/twenty-for-twenty/src/utils/is-defined.ts b/packages/twenty-apps/internal/twenty-for-twenty/src/utils/is-defined.ts new file mode 100644 index 0000000000..f0f3ead7d6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-for-twenty/src/utils/is-defined.ts @@ -0,0 +1,2 @@ +export const isDefined = (value: T | null | undefined): value is T => + value !== null && value !== undefined; diff --git a/packages/twenty-apps/internal/twenty-for-twenty/tsconfig.json b/packages/twenty-apps/internal/twenty-for-twenty/tsconfig.json index d574c8c810..79d91179db 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/tsconfig.json +++ b/packages/twenty-apps/internal/twenty-for-twenty/tsconfig.json @@ -24,7 +24,10 @@ "resolveJsonModule": true, "paths": { "src/*": ["./src/*"], - "~/*": ["./*"] + "~/*": ["./*"], + "@utils/*": ["./src/utils/*"], + "@constants/*": ["./src/constants/*"], + "@modules/*": ["./src/modules/*"] } }, "exclude": [ diff --git a/packages/twenty-apps/internal/twenty-for-twenty/yarn.lock b/packages/twenty-apps/internal/twenty-for-twenty/yarn.lock index 89d9ec5cd9..51a81f5b9e 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/yarn.lock +++ b/packages/twenty-apps/internal/twenty-for-twenty/yarn.lock @@ -1092,6 +1092,13 @@ __metadata: languageName: node linkType: hard +"@types/prop-types@npm:*": + version: 15.7.15 + resolution: "@types/prop-types@npm:15.7.15" + checksum: 10c0/b59aad1ad19bf1733cf524fd4e618196c6c7690f48ee70a327eb450a42aab8e8a063fbe59ca0a5701aebe2d92d582292c0fb845ea57474f6a15f6994b0e260b2 + languageName: node + linkType: hard + "@types/qs@npm:^6.9.0": version: 6.15.0 resolution: "@types/qs@npm:6.15.0" @@ -1099,12 +1106,13 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:^19.0.0": - version: 19.2.14 - resolution: "@types/react@npm:19.2.14" +"@types/react@npm:^18.2.0": + version: 18.3.28 + resolution: "@types/react@npm:18.3.28" dependencies: + "@types/prop-types": "npm:*" csstype: "npm:^3.2.2" - checksum: 10c0/7d25bf41b57719452d86d2ac0570b659210402707313a36ee612666bf11275a1c69824f8c3ee1fdca077ccfe15452f6da8f1224529b917050eb2d861e52b59b7 + checksum: 10c0/683e19cd12b5c691215529af2e32b5ffbaccae3bf0ba93bfafa0e460e8dfee18423afed568be2b8eadf4b837c3749dd296a4f64e2d79f68fa66962c05f5af661 languageName: node linkType: hard @@ -2684,6 +2692,13 @@ __metadata: languageName: node linkType: hard +"js-tokens@npm:^3.0.0 || ^4.0.0": + version: 4.0.0 + resolution: "js-tokens@npm:4.0.0" + checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed + languageName: node + linkType: hard + "js-tokens@npm:^9.0.1": version: 9.0.1 resolution: "js-tokens@npm:9.0.1" @@ -2808,6 +2823,17 @@ __metadata: languageName: node linkType: hard +"loose-envify@npm:^1.1.0": + version: 1.4.0 + resolution: "loose-envify@npm:1.4.0" + dependencies: + js-tokens: "npm:^3.0.0 || ^4.0.0" + bin: + loose-envify: cli.js + checksum: 10c0/655d110220983c1a4b9c0c679a2e8016d4b67f6e9c7b5435ff5979ecdb20d0813f4dec0a08674fcbdd4846a3f07edbb50a36811fd37930b94aaa0d9daceb017e + languageName: node + linkType: hard + "loupe@npm:^3.1.0, loupe@npm:^3.1.4": version: 3.2.1 resolution: "loupe@npm:3.2.1" @@ -3386,6 +3412,18 @@ __metadata: languageName: node linkType: hard +"react-dom@npm:^18.2.0": + version: 18.3.1 + resolution: "react-dom@npm:18.3.1" + dependencies: + loose-envify: "npm:^1.1.0" + scheduler: "npm:^0.23.2" + peerDependencies: + react: ^18.3.1 + checksum: 10c0/a752496c1941f958f2e8ac56239172296fcddce1365ce45222d04a1947e0cc5547df3e8447f855a81d6d39f008d7c32eab43db3712077f09e3f67c4874973e85 + languageName: node + linkType: hard + "react-dom@npm:^19.0.0": version: 19.2.5 resolution: "react-dom@npm:19.2.5" @@ -3408,6 +3446,15 @@ __metadata: languageName: node linkType: hard +"react@npm:^18.2.0": + version: 18.3.1 + resolution: "react@npm:18.3.1" + dependencies: + loose-envify: "npm:^1.1.0" + checksum: 10c0/283e8c5efcf37802c9d1ce767f302dd569dd97a70d9bb8c7be79a789b9902451e0d16334b05d73299b20f048cbc3c7d288bbbde10b701fa194e2089c237dbea3 + languageName: node + linkType: hard + "react@npm:^19.0.0": version: 19.2.5 resolution: "react@npm:19.2.5" @@ -3458,24 +3505,6 @@ __metadata: languageName: node linkType: hard -"twenty-for-twenty@workspace:.": - version: 0.0.0-use.local - resolution: "twenty-for-twenty@workspace:." - dependencies: - "@types/node": "npm:^24.7.2" - "@types/react": "npm:^19.0.0" - oxlint: "npm:^0.16.0" - react: "npm:^19.0.0" - react-dom: "npm:^19.0.0" - resend: "npm:^6.12.0" - twenty-client-sdk: "npm:1.22.0" - twenty-sdk: "npm:1.22.0" - typescript: "npm:^5.9.3" - vite-tsconfig-paths: "npm:^4.2.1" - vitest: "npm:^3.1.1" - languageName: unknown - linkType: soft - "resolve-from@npm:5.0.0": version: 5.0.0 resolution: "resolve-from@npm:5.0.0" @@ -3652,6 +3681,15 @@ __metadata: languageName: node linkType: hard +"scheduler@npm:^0.23.2": + version: 0.23.2 + resolution: "scheduler@npm:0.23.2" + dependencies: + loose-envify: "npm:^1.1.0" + checksum: 10c0/26383305e249651d4c58e6705d5f8425f153211aef95f15161c151f7b8de885f24751b377e4a0b3dd42cce09aad3f87a61dab7636859c0d89b7daf1a1e2a5c78 + languageName: node + linkType: hard + "scheduler@npm:^0.27.0": version: 0.27.0 resolution: "scheduler@npm:0.27.0" @@ -4116,21 +4154,51 @@ __metadata: languageName: node linkType: hard -"twenty-client-sdk@npm:1.22.0": - version: 1.22.0 - resolution: "twenty-client-sdk@npm:1.22.0" +"twenty-client-sdk@npm:2.0.0": + version: 2.0.0 + resolution: "twenty-client-sdk@npm:2.0.0" dependencies: "@genql/cli": "npm:^3.0.3" "@genql/runtime": "npm:^2.10.0" esbuild: "npm:^0.25.0" graphql: "npm:^16.8.1" - checksum: 10c0/243e95203a35e8feeab568de99dd2f844251d5d16c13c6c9850dd1b4fc3e14a3f752a5a2e9ff654f920fca5c0617974139383b7e74ae3a11718f15a933992a36 + checksum: 10c0/f8cbeef32d72febe562387b881b89b8edf3eb92ab4268b47d24286b3c291e032b6cc065d9e9cd7e42a7ea0f27741651d3a270623f5c0478782eafbe3b62f30f8 languageName: node linkType: hard -"twenty-sdk@npm:1.22.0": - version: 1.22.0 - resolution: "twenty-sdk@npm:1.22.0" +"twenty-client-sdk@npm:twenty-client-sdk@1.23.0-canary.1": + version: 1.23.0-canary.1 + resolution: "twenty-client-sdk@npm:1.23.0-canary.1" + dependencies: + "@genql/cli": "npm:^3.0.3" + "@genql/runtime": "npm:^2.10.0" + esbuild: "npm:^0.25.0" + graphql: "npm:^16.8.1" + checksum: 10c0/fdf4fbb736c9a8e218ed34cc941a20562990be138e27b462ffed9953bbeeed6ac77b15a4a6f664341fabe8c83b78d273f79d3b8c720c8472db9f7db939f49491 + languageName: node + linkType: hard + +"twenty-for-twenty@workspace:.": + version: 0.0.0-use.local + resolution: "twenty-for-twenty@workspace:." + dependencies: + "@types/node": "npm:^24.7.2" + "@types/react": "npm:^18.2.0" + oxlint: "npm:^0.16.0" + react: "npm:^18.2.0" + react-dom: "npm:^18.2.0" + resend: "npm:^6.12.0" + twenty-client-sdk: "npm:twenty-client-sdk@1.23.0-canary.1" + twenty-sdk: "npm:twenty-sdk@2.0.0" + typescript: "npm:^5.9.3" + vite-tsconfig-paths: "npm:^4.2.1" + vitest: "npm:^3.1.1" + languageName: unknown + linkType: soft + +"twenty-sdk@npm:twenty-sdk@2.0.0": + version: 2.0.0 + resolution: "twenty-sdk@npm:2.0.0" dependencies: "@genql/cli": "npm:^3.0.3" "@genql/runtime": "npm:^2.10.0" @@ -4150,7 +4218,7 @@ __metadata: react: "npm:^19.0.0" react-dom: "npm:^19.0.0" tinyglobby: "npm:^0.2.15" - twenty-client-sdk: "npm:1.22.0" + twenty-client-sdk: "npm:2.0.0" typescript: "npm:^5.9.2" uuid: "npm:^13.0.0" vite: "npm:^7.0.0" @@ -4158,7 +4226,7 @@ __metadata: zod: "npm:^4.1.11" bin: twenty: dist/cli.cjs - checksum: 10c0/1415577597137998e5ffbc8319a9fd24359ca8023af365fe04c684ba1eae1fd4977f64943742e20a5f815ccaf8d7baf6d9246f99df83ef2b772c57b135c3a87e + checksum: 10c0/ea0d50a002e88b177555987840fa9dfeda0f3482f42240bda04a79485f69e9717492a265de8080f599e03960859cbcab0e686c6b6bf8fe0872139f9dc1645dd5 languageName: node linkType: hard