Resend app improvements (#19986)
## Summary Major overhaul of the `twenty-for-twenty` Resend app to make sync more reliable, observable, and feature-complete. ### SDK upgrade - Bumps `twenty-sdk` to `2.0.0` and `twenty-client-sdk` to `1.23.0-canary.1` - Pins React back to `^18.2.0` to match the SDK ### Sync engine rewrite - Splits the single `sync-resend-data` job into 4 staggered cron-driven logic functions: **Emails**, **Contacts**, **Broadcasts (+ segments + dependencies)**, **Templates** — each running every 5 minutes on a different minute offset with per-slot timeouts - Adds a new `ResendSyncCursor` object + `with-sync-cursor` orchestration so each step persists its progress, last run timestamp, and last run status - Introduces an `INITIAL_SYNC_MODE` app variable + `resend-initial-sync-mode-monitor` that flips to intermediate sync once every cursor is empty (intermediate sync only refetches the last 7 days of emails) - Stops auto-creating People from Resend contacts; instead backfills `personId` on Resend contacts/emails by matching existing People by email - Renames `on-*-deleted` handlers to `on-*-destroyed` and removes from Resend on destroy (not soft delete) - Adds rate-limit retry, paginated `for-each-page`, typed-client, and existing-IDs lookup helpers ### New objects & fields - New `ResendTopic` object with relation to `ResendBroadcast` (+ navigation menu item, view, page layout) - New `ResendSyncCursor` object (step / cursor / last run at / last run status) - Adds `html` and `text` fields on `ResendBroadcast`; removes raw `htmlBody`/`textBody`/`tags` from `ResendEmail` ### New UI - **Sync Status standalone page** (`ResendSyncStatus` front component + nav item) showing live cursor / last run state per step - **Person Resend Email Stats** front component: deliverability rate + per-status breakdown with progress bars - **Email Broadcast HTML viewer** front component renders an individual email against its parent broadcast's HTML; new dedicated **Broadcast HTML viewer** - Adds Resend Broadcast record page layout (Home / Preview / Timeline / Tasks / Notes / Files tabs) ### Tests - ~25 new unit / integration test files covering sync utilities, cursor lifecycle, webhook handler, email-stats computation, sync-status page resolution, and rate-limit retry - Replaces legacy `fetch-all-paginated` tests with `for-each-page` tests
This commit is contained in:
@@ -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"
|
||||
|
||||
+18
-12
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
+92
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
+31
@@ -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;
|
||||
+127
-14
@@ -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';
|
||||
|
||||
+108
@@ -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<ResendEmailStatus> =
|
||||
[
|
||||
'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;
|
||||
+265
@@ -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) => (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: `0 ${themeCssVariables.spacing[2]}`,
|
||||
height: themeCssVariables.spacing[5],
|
||||
borderRadius: themeCssVariables.border.radius.sm,
|
||||
background: themeCssVariables.tag.background[color],
|
||||
color: themeCssVariables.tag.text[color],
|
||||
fontSize: themeCssVariables.font.size.xs,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(clamped)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: PROGRESS_BAR_HEIGHT,
|
||||
borderRadius: themeCssVariables.border.radius.pill,
|
||||
background: themeCssVariables.tag.background[color],
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${clamped}%`,
|
||||
height: '100%',
|
||||
borderRadius: themeCssVariables.border.radius.pill,
|
||||
background: themeCssVariables.tag.text[color],
|
||||
transition: 'width 0.3s linear',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (): Record<string, React.CSSProperties> => ({
|
||||
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 (
|
||||
<div style={styles.container}>
|
||||
<Callout
|
||||
variant="neutral"
|
||||
title="Loading email stats"
|
||||
description="Computing per-status counts and deliverability\u2026"
|
||||
Icon={IconRefresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(error)) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<Callout
|
||||
variant="error"
|
||||
title="Failed to load email stats"
|
||||
description={error}
|
||||
Icon={IconAlertCircle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stats.total === 0) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<Callout
|
||||
variant="info"
|
||||
title="No Resend emails yet"
|
||||
description="This person has no linked Resend emails to compute stats from."
|
||||
Icon={IconMail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardHeader}>
|
||||
<div style={styles.h2TitleNoMargin}>
|
||||
<H2Title title="Deliverability rate" />
|
||||
</div>
|
||||
<StatusPill color={rateColor} text={rateLabel} />
|
||||
</div>
|
||||
<MiniProgressBar
|
||||
value={isDefined(deliverabilityRate) ? deliverabilityRate * 100 : 0}
|
||||
color={rateColor}
|
||||
/>
|
||||
<div style={styles.rateMeta}>
|
||||
{stats.groupCounts.reached} delivered out of{' '}
|
||||
{deliverabilityDenominator} concluded ({stats.total} total emails)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.card}>
|
||||
<div style={styles.h2TitleNoMargin}>
|
||||
<H2Title title="Status breakdown" />
|
||||
</div>
|
||||
<div style={styles.legend}>
|
||||
{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 (
|
||||
<div key={status} style={styles.statusRow}>
|
||||
<StatusPill color={meta.color} text={meta.label} />
|
||||
<MiniProgressBar value={percentage} color={meta.color} />
|
||||
<span style={styles.statusCount}>
|
||||
{count} ({formatPercentage(count, stats.total)})
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
+126
@@ -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<PersonResendEmailStatsState>({
|
||||
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<string, unknown> = {
|
||||
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<ResendEmailLastEventNode>(
|
||||
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;
|
||||
};
|
||||
+106
@@ -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);
|
||||
});
|
||||
});
|
||||
+71
@@ -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<ResendEmailStatus, number>;
|
||||
groupCounts: Record<EmailStatusGroup, number>;
|
||||
deliverabilityRate: number | null;
|
||||
};
|
||||
|
||||
const buildEmptyStatusCounts = (): Record<ResendEmailStatus, number> => ({
|
||||
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<EmailStatusGroup, number> => ({
|
||||
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,
|
||||
};
|
||||
};
|
||||
+30
-21
@@ -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<string, React.CSSProperties> => ({
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#999',
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
No HTML content available
|
||||
</div>
|
||||
);
|
||||
return <div style={styles.emptyState}>No HTML content available</div>;
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+47
-28
@@ -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<string, React.CSSProperties> => {
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#999',
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{loadingText}
|
||||
</div>
|
||||
);
|
||||
return <div style={styles.loader}>{loadingText}</div>;
|
||||
}
|
||||
|
||||
if (isDefined(error)) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
color: '#999',
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<div>{error}</div>
|
||||
<div style={styles.stateContainer}>
|
||||
<Callout
|
||||
variant="error"
|
||||
title="Failed to load content"
|
||||
description={error}
|
||||
Icon={IconAlertCircle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
<div style={styles.previewWrapper}>
|
||||
<HtmlPreview html={html} />
|
||||
</div>
|
||||
);
|
||||
|
||||
+19
@@ -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 = () => (
|
||||
<RecordHtmlViewer
|
||||
objectName="resendBroadcast"
|
||||
loadingText="Loading broadcast..."
|
||||
/>
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
+92
@@ -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<string, React.CSSProperties> => {
|
||||
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 <div style={styles.loader}>Loading broadcast preview...</div>;
|
||||
}
|
||||
|
||||
if (isDefined(error)) {
|
||||
return (
|
||||
<div style={styles.stateContainer}>
|
||||
<Callout
|
||||
variant="error"
|
||||
title="Failed to load broadcast preview"
|
||||
description={error}
|
||||
Icon={IconAlertCircle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasBroadcast) {
|
||||
return (
|
||||
<div style={styles.stateContainer}>
|
||||
<Callout
|
||||
variant="info"
|
||||
title="No broadcast linked"
|
||||
description="This email is not linked to a broadcast, so there is no HTML preview to display."
|
||||
Icon={IconInfoCircle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.previewWrapper}>
|
||||
<HtmlPreview html={html} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
-15
@@ -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 = () => (
|
||||
<RecordHtmlViewer objectName="resendEmail" loadingText="Loading email..." />
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
+2
-2
@@ -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 = () => (
|
||||
<RecordHtmlViewer
|
||||
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecordId } from 'twenty-sdk/front-component';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { isDefined } from '@utils/is-defined';
|
||||
|
||||
type RecordHtmlState = {
|
||||
html: string | null;
|
||||
@@ -32,7 +33,7 @@ export const useRecordHtml = (objectName: string): RecordHtmlState => {
|
||||
htmlBody: true,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
.then((result: unknown) => {
|
||||
const record = (result as Record<string, unknown>)[objectName] as
|
||||
| { htmlBody?: string | null }
|
||||
| undefined;
|
||||
|
||||
+96
@@ -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<RelatedBroadcastHtmlState>({
|
||||
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<string, unknown>).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;
|
||||
};
|
||||
+259
@@ -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<CursorRowStatus, StatusThemeColor> = {
|
||||
SUCCESS: 'green',
|
||||
FAILED: 'red',
|
||||
IN_PROGRESS: 'orange',
|
||||
};
|
||||
|
||||
const STATUS_LABEL_BY_RUN_STATUS: Record<CursorRowStatus, string> = {
|
||||
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<string, React.CSSProperties> => ({
|
||||
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<FetchState>({
|
||||
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<CursorRow>(
|
||||
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 (
|
||||
<div style={styles.container}>
|
||||
<Callout
|
||||
variant="neutral"
|
||||
title="Loading sync status"
|
||||
description="Fetching cursors and queue counts…"
|
||||
Icon={IconRefresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(state.error)) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<Callout
|
||||
variant="error"
|
||||
title="Failed to load sync status"
|
||||
description={state.error}
|
||||
Icon={IconAlertCircle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rowByStep = new Map<SyncCursorStep, CursorRow>();
|
||||
|
||||
for (const row of state.rows) {
|
||||
rowByStep.set(row.step, row);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{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 (
|
||||
<div key={step} style={styles.card}>
|
||||
<div style={styles.cardHeader}>
|
||||
<div style={styles.h2TitleNoMargin}>
|
||||
<H2Title title={formatStepLabel(step)} />
|
||||
</div>
|
||||
<Status
|
||||
color={statusColor}
|
||||
text={statusLabel}
|
||||
isLoaderVisible={runStatus === 'IN_PROGRESS'}
|
||||
/>
|
||||
</div>
|
||||
<div style={styles.cardLine}>
|
||||
Last run: {formatTimestamp(lastRunAt)}
|
||||
</div>
|
||||
{isDefined(cursor) && cursor !== '' && (
|
||||
<div style={styles.cardLine}>
|
||||
Resume cursor: <code style={styles.cursorCode}>{cursor}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME = 'Sync Status';
|
||||
+12
@@ -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,
|
||||
});
|
||||
+52
-43
@@ -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<string> => {
|
||||
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<void> => {
|
||||
await metadataClient.mutation({
|
||||
updateOneApplicationVariable: {
|
||||
__args: {
|
||||
input: {
|
||||
id: syncFunction.id,
|
||||
payload: {} as Record<string, never>,
|
||||
},
|
||||
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 = () => <Command execute={execute} />;
|
||||
@@ -76,7 +84,8 @@ const SyncResendData = () => <Command execute={execute} />;
|
||||
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: {
|
||||
|
||||
+112
@@ -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');
|
||||
});
|
||||
});
|
||||
+39
@@ -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<string> => {
|
||||
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;
|
||||
};
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+2
-2
@@ -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',
|
||||
});
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+23
@@ -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',
|
||||
});
|
||||
+2
-2
@@ -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',
|
||||
});
|
||||
|
||||
+2
-2
@@ -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',
|
||||
});
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+24
@@ -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',
|
||||
});
|
||||
+2
-2
@@ -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:
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
+21
@@ -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,
|
||||
});
|
||||
+2
-2
@@ -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:
|
||||
|
||||
+19
@@ -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,
|
||||
});
|
||||
+19
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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: [
|
||||
|
||||
+1
-28
@@ -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,
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+125
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+115
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
+143
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
+4
-4
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+42
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+13
-2
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
+2
-2
@@ -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: [
|
||||
{
|
||||
|
||||
+34
-23
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+79
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
+91
@@ -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<typeof vi.fn>,
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
+262
@@ -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<ReturnType<ResendListFunction<Item>>>;
|
||||
|
||||
const page = (ids: string[], hasMore: boolean): ListResponse => ({
|
||||
data: { data: ids.map((id) => ({ id })), has_more: hasMore },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const createMockListFunction = (
|
||||
pages: ListResponse[],
|
||||
): {
|
||||
listFunction: ResendListFunction<Item>;
|
||||
calls: { limit: number; after?: string }[];
|
||||
} => {
|
||||
const calls: { limit: number; after?: string }[] = [];
|
||||
let index = 0;
|
||||
|
||||
const listFunction: ResendListFunction<Item> = 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();
|
||||
});
|
||||
});
|
||||
+94
@@ -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 <email>" format', () => {
|
||||
expect(toEmailsField('Thomas <thomas@mail.twenty.com>')).toEqual({
|
||||
primaryEmail: 'thomas@mail.twenty.com',
|
||||
additionalEmails: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts the email from a quoted display name', () => {
|
||||
expect(toEmailsField('"Last, First" <thomas@mail.twenty.com>')).toEqual({
|
||||
primaryEmail: 'thomas@mail.twenty.com',
|
||||
additionalEmails: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes each entry of an array of mixed formats', () => {
|
||||
expect(
|
||||
toEmailsField([
|
||||
'Alice <alice@example.com>',
|
||||
'bob@example.com',
|
||||
'"Carol C." <carol@example.com>',
|
||||
]),
|
||||
).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 <email>" format', () => {
|
||||
expect(toEmailsField('Thomas <Thomas@Mail.Twenty.Com>')).toEqual({
|
||||
primaryEmail: 'thomas@mail.twenty.com',
|
||||
additionalEmails: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('lowercases each entry of an array', () => {
|
||||
expect(
|
||||
toEmailsField([
|
||||
'Alice <Alice@Example.com>',
|
||||
'BOB@example.com',
|
||||
'"Carol C." <Carol@EXAMPLE.com>',
|
||||
]),
|
||||
).toEqual({
|
||||
primaryEmail: 'alice@example.com',
|
||||
additionalEmails: ['bob@example.com', 'carol@example.com'],
|
||||
});
|
||||
});
|
||||
});
|
||||
+76
@@ -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);
|
||||
});
|
||||
});
|
||||
-116
@@ -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<ReturnType<ResendListFn<Item>>>;
|
||||
|
||||
const page = (ids: string[], hasMore: boolean): ListResponse => ({
|
||||
data: { data: ids.map((id) => ({ id })), has_more: hasMore },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const makeListFn = (
|
||||
pages: ListResponse[],
|
||||
): { fn: ResendListFn<Item>; calls: { limit: number; after?: string }[] } => {
|
||||
const calls: { limit: number; after?: string }[] = [];
|
||||
let index = 0;
|
||||
|
||||
const fn: ResendListFn<Item> = 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<Item> = 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
-53
@@ -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<T> = (params: {
|
||||
limit: number;
|
||||
after?: string;
|
||||
}) => Promise<{
|
||||
data: { data: T[]; has_more: boolean } | null;
|
||||
error: unknown;
|
||||
}>;
|
||||
|
||||
export const fetchAllPaginated = async <T extends { id: string }>(
|
||||
listFn: ResendListFn<T>,
|
||||
label = 'items',
|
||||
): Promise<T[]> => {
|
||||
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;
|
||||
};
|
||||
+65
-25
@@ -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<string | undefined> => {
|
||||
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<string | undefined> => {
|
||||
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<string | undefined> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
+56
@@ -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<string | undefined | null>,
|
||||
): Promise<Map<string, string>> => {
|
||||
const personIdByEmail = new Map<string, string>();
|
||||
|
||||
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;
|
||||
};
|
||||
+5
-7
@@ -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<string, unknown>)[objectNamePlural] as
|
||||
| ConnectionResult
|
||||
| undefined;
|
||||
const connection = extractConnection<ResendIdNode>(result, objectNamePlural);
|
||||
|
||||
return connection?.edges[0]?.node.id;
|
||||
return connection.edges[0]?.node.id;
|
||||
};
|
||||
|
||||
+72
@@ -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<string | undefined | null>,
|
||||
): Promise<Map<string, ResendContactLookup>> => {
|
||||
const contactByEmail = new Map<string, ResendContactLookup>();
|
||||
|
||||
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<ResendContactNode>(
|
||||
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;
|
||||
};
|
||||
+55
@@ -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<string | undefined | null>,
|
||||
): Promise<Map<string, string>> => {
|
||||
const map = new Map<string, string>();
|
||||
|
||||
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<ResendIdNode>(result, objectNamePlural);
|
||||
|
||||
for (const edge of connection.edges) {
|
||||
if (isDefined(edge.node.resendId)) {
|
||||
map.set(edge.node.resendId, edge.node.id);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
+121
@@ -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<T> = (paginationParameters: {
|
||||
limit: number;
|
||||
after?: string;
|
||||
}) => Promise<{
|
||||
data: { data: T[]; has_more: boolean } | null;
|
||||
error: unknown;
|
||||
}>;
|
||||
|
||||
export type ForEachPageOptions = {
|
||||
startCursor?: string;
|
||||
onCursorAdvance?: (cursor: string) => Promise<void>;
|
||||
deadlineAtMs?: number;
|
||||
};
|
||||
|
||||
export type OnPageResult = {
|
||||
ok: boolean;
|
||||
stop?: boolean;
|
||||
errors?: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
export type OnPageHandler<T> = (
|
||||
items: T[],
|
||||
pageNumber: number,
|
||||
) => Promise<OnPageResult | void>;
|
||||
|
||||
export type ForEachPageResult = {
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
export const forEachPage = async <T extends { id: string }>(
|
||||
listFunction: ResendListFunction<T>,
|
||||
onPage: OnPageHandler<T>,
|
||||
label = 'items',
|
||||
options?: ForEachPageOptions,
|
||||
): Promise<ForEachPageResult> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined } from '@utils/is-defined';
|
||||
|
||||
export type LastEvent =
|
||||
| 'SENT'
|
||||
|
||||
+22
-4
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
export type PageInfo = {
|
||||
hasNextPage: boolean;
|
||||
endCursor: string | null;
|
||||
};
|
||||
|
||||
export type Connection<TNode> = {
|
||||
edges: Array<{ node: TNode }>;
|
||||
pageInfo?: PageInfo;
|
||||
};
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
export const extractConnection = <TNode>(
|
||||
result: unknown,
|
||||
field: string,
|
||||
): Connection<TNode> => {
|
||||
if (!isObject(result)) {
|
||||
return { edges: [] };
|
||||
}
|
||||
|
||||
const candidate = result[field];
|
||||
|
||||
if (!isObject(candidate)) {
|
||||
return { edges: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
edges: Array.isArray((candidate as Connection<TNode>).edges)
|
||||
? (candidate as Connection<TNode>).edges
|
||||
: [],
|
||||
pageInfo: (candidate as Connection<TNode>).pageInfo,
|
||||
};
|
||||
};
|
||||
|
||||
export const extractMutationRecord = <T = { id: string }>(
|
||||
result: unknown,
|
||||
field: string,
|
||||
): T | undefined => {
|
||||
if (!isObject(result)) return undefined;
|
||||
|
||||
const candidate = result[field];
|
||||
|
||||
if (!isObject(candidate)) return undefined;
|
||||
|
||||
return candidate as T;
|
||||
};
|
||||
+55
-13
@@ -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<void>((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<string, number>();
|
||||
const DEFAULT_CHANNEL = 'default';
|
||||
|
||||
export const withRateLimitRetry = async <T>(
|
||||
fn: () => Promise<T>,
|
||||
options?: { channel?: string },
|
||||
): Promise<T> => {
|
||||
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);
|
||||
}
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import type { SyncCursorStep } from '@modules/resend/sync/cursor/types/sync-cursor-step';
|
||||
|
||||
export const RESEND_SYNC_CURSOR_STEPS: ReadonlyArray<SyncCursorStep> = [
|
||||
'TOPICS',
|
||||
'SEGMENTS',
|
||||
'TEMPLATES',
|
||||
'CONTACTS',
|
||||
'EMAILS',
|
||||
'BROADCASTS',
|
||||
];
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
export type SyncCursorStep =
|
||||
| 'SEGMENTS'
|
||||
| 'TEMPLATES'
|
||||
| 'CONTACTS'
|
||||
| 'EMAILS'
|
||||
| 'BROADCASTS'
|
||||
| 'TOPICS';
|
||||
|
||||
export type SyncCursorRow = {
|
||||
id: string;
|
||||
step: SyncCursorStep;
|
||||
cursor: string | null;
|
||||
};
|
||||
+88
@@ -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<Record<string, unknown>> = [];
|
||||
|
||||
const query = vi.fn(async () => ({
|
||||
resendSyncCursors: {
|
||||
edges: nodes.map((node) => ({ node })),
|
||||
},
|
||||
}));
|
||||
|
||||
const mutation = vi.fn(async (m: Record<string, unknown>) => {
|
||||
mutationCalls.push(m);
|
||||
|
||||
return { updateResendSyncCursor: { id: 'updated' } };
|
||||
});
|
||||
|
||||
const client = { query, mutation } as unknown as CoreApiClient;
|
||||
|
||||
return { client, mutationCalls, query, mutation };
|
||||
};
|
||||
|
||||
const extractUpdateArgs = (call: Record<string, unknown>) => {
|
||||
const updateBlock = call.updateResendSyncCursor as
|
||||
| { __args?: { id?: string; data?: Record<string, unknown> } }
|
||||
| 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');
|
||||
});
|
||||
});
|
||||
+168
@@ -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<Record<string, unknown>> = [];
|
||||
const queryCalls: Array<Record<string, unknown>> = [];
|
||||
|
||||
const query = vi.fn(async (q: Record<string, unknown>) => {
|
||||
queryCalls.push(q);
|
||||
|
||||
return {
|
||||
resendSyncCursors: {
|
||||
edges: existingNode ? [{ node: existingNode }] : [],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mutation = vi.fn(async (m: Record<string, unknown>) => {
|
||||
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<Record<string, unknown>>,
|
||||
predicate: (data: Record<string, unknown>) => boolean,
|
||||
): Record<string, unknown> | undefined => {
|
||||
for (const call of calls) {
|
||||
const updateBlock = call.updateResendSyncCursor as
|
||||
| { __args?: { data?: Record<string, unknown> } }
|
||||
| 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();
|
||||
});
|
||||
});
|
||||
+130
@@ -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<SyncCursorRow | null> => {
|
||||
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<SyncCursorNode>(
|
||||
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<SyncCursorRow> => {
|
||||
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')
|
||||
);
|
||||
};
|
||||
+46
@@ -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<void> => {
|
||||
const queryResult = await client.query({
|
||||
resendSyncCursors: {
|
||||
__args: {
|
||||
first: RESEND_SYNC_CURSOR_STEPS.length + 5,
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const connection = extractConnection<SyncCursorIdNode>(
|
||||
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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
+35
@@ -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<void> => {
|
||||
await client.mutation({
|
||||
updateResendSyncCursor: {
|
||||
__args: { id, data: { cursor } },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const updateCursorRow = async (
|
||||
client: CoreApiClient,
|
||||
id: string,
|
||||
data: CursorUpdate,
|
||||
): Promise<void> => {
|
||||
await client.mutation({
|
||||
updateResendSyncCursor: {
|
||||
__args: { id, data },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
+80
@@ -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<void>;
|
||||
};
|
||||
|
||||
export type SyncCursorRunResult<TValue> = {
|
||||
value: TValue;
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
export type WithSyncCursorOptions = {
|
||||
preserveCursor?: boolean;
|
||||
};
|
||||
|
||||
export const withSyncCursor = async <TValue>(
|
||||
client: CoreApiClient,
|
||||
step: SyncCursorStep,
|
||||
runWithCursor: (
|
||||
context: SyncCursorContext,
|
||||
) => Promise<SyncCursorRunResult<TValue>>,
|
||||
options?: WithSyncCursorOptions,
|
||||
): Promise<TValue> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
+73
@@ -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<typeof vi.fn>).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');
|
||||
});
|
||||
});
|
||||
+109
@@ -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 = <T>(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<typeof vi.fn>).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');
|
||||
});
|
||||
});
|
||||
+43
@@ -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<typeof vi.fn>).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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
+74
@@ -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<typeof vi.fn>).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());
|
||||
});
|
||||
});
|
||||
+59
@@ -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<typeof vi.fn>).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' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
+7
-7
@@ -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<ResendContactRecord>
|
||||
@@ -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,
|
||||
|
||||
+13
-13
@@ -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<ResendContactRecord>
|
||||
type ContactDestroyEvent = DatabaseEventPayload<
|
||||
ObjectRecordDestroyEvent<ResendContactRecord>
|
||||
>;
|
||||
|
||||
const handler = async (
|
||||
event: ContactDeleteEvent,
|
||||
event: ContactDestroyEvent,
|
||||
): Promise<object | undefined> => {
|
||||
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',
|
||||
},
|
||||
});
|
||||
+34
-15
@@ -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<ResendContactRecord>
|
||||
>;
|
||||
|
||||
const valuesEqual = (a: unknown, b: unknown): boolean =>
|
||||
JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
|
||||
const handler = async (
|
||||
event: ContactUpdateEvent,
|
||||
): Promise<object | undefined> => {
|
||||
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<string, unknown> = { 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<typeof resend.contacts.update>[0],
|
||||
const { error } = await resendClient.contacts.update(
|
||||
updatePayload as Parameters<typeof resendClient.contacts.update>[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,
|
||||
};
|
||||
};
|
||||
|
||||
+6
-6
@@ -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<ResendSegmentRecord>
|
||||
@@ -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: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user