From 13c22348567d7ef236e929396d1379993a7a3e2c Mon Sep 17 00:00:00 2001 From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:14:25 +0500 Subject: [PATCH] feat: emit metadata events for schema changes with actor context for webhooks (#17622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR adds **metadata eventing**: when schema metadata (objectMetadata, fieldMetadata, view, viewField, etc.) is created, updated, or deleted, we now emit events that can trigger webhooks and future audit logs. It also adds **actor context** (`userId`, `workspaceMemberId`) to those events so subscribers can attribute changes to a user or API key. ## What changed ### 1. Metadata eventing (first commit) - **MetadataEventEmitter** New service that emits batch events after successful workspace migrations. Event names follow `metadata.{entity}.{action}` (e.g. `metadata.objectMetadata.created`, `metadata.fieldMetadata.updated`). - **MetadataEventsToDbListener** Listens for metadata events and enqueues webhook delivery via `CallWebhookJobsForMetadataJob`. - **Event types** (twenty-shared) `MetadataEventAction`, `MetadataEventBatch`, and record event types for create/update/delete. - **WorkspaceMigrationValidateBuildAndRunService** Calls the metadata event emitter after running migrations so all metadata changes (from any module) emit events from a single place. - **Create events** Sourced from the create action payload (`flatEntity` / `flatFieldMetadatas`) because `fromToAllFlatEntityMaps` does not provide a before/after diff for creates. Update/delete events still use the fromToAllFlatEntityMaps comparison. ### 2. Actor context (second commit) - **MetadataEventEmitter** Accepts optional `actorContext` (`userId`, `workspaceMemberId`) and includes it on emitted batch events. - **WorkspaceMigrationValidateBuildAndRunService** Passes `actorContext` from the request into the metadata event emitter. - **Metadata resolvers & services** All metadata modules resolve `@AuthUser({ allowUndefined: true })` and `@AuthUserWorkspaceId()` and pass `userId` and `workspaceMemberId` through to the migration/event pipeline. Both are optional so API-key–authenticated requests (no user) still emit events without a user identity. Shared some questions on Discord about the PR. --------- Co-authored-by: Félix Malfait Co-authored-by: prastoin --- .../components/SettingsDatabaseEventsForm.tsx | 28 +-- .../components/WebhookEntitySelect.tsx | 236 ++++++++++++++++++ .../hooks/__tests__/useWebhookForm.test.tsx | 3 +- .../developers/hooks/useWebhookForm.ts | 2 +- .../addEmptyOperationIfNecessary.test.ts | 32 ++- .../parseOperationsFromStrings.test.ts | 26 +- .../utils/addEmptyOperationIfNecessary.ts | 27 +- .../utils/parseOperationsFromStrings.ts | 12 +- .../engine/core-modules/core-engine.module.ts | 2 + .../metadata-events-to-db.listener.ts | 53 ++++ .../metadata-event-emitter.module.ts | 11 + .../metadata-event-emitter.ts | 121 +++++++++ .../types/metadata-event-batch.type.ts | 19 ++ .../utils/compute-metadata-event-name.util.ts | 7 + .../field-metadata/field-metadata.resolver.ts | 2 +- .../call-webhook-jobs-for-metadata.job.ts | 99 ++++++++ .../webhook/jobs/call-webhook-jobs.job.ts | 6 +- .../webhook/jobs/call-webhook.job.ts | 22 +- .../webhook/jobs/webhook-job.module.ts | 7 +- .../webhook/types/webhook-job-data.type.ts | 27 ++ ...transform-event-batch-to-webhook-events.ts | 2 +- .../webhook/webhook.service.ts | 2 +- ...igration-validate-build-and-run-service.ts | 10 +- .../workspace-migration-action-common.ts | 6 +- ...runner-action-handler-service.interface.ts | 11 +- .../workspace-migration-runner.service.ts | 2 +- .../types/metadata-event.ts | 12 +- ...metadata-events-from-create-action.util.ts | 12 +- ...metadata-events-from-delete-action.util.ts | 3 +- ...metadata-events-from-update-action.util.ts | 11 +- 30 files changed, 731 insertions(+), 82 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/developers/components/WebhookEntitySelect.tsx create mode 100644 packages/twenty-server/src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener.ts create mode 100644 packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.ts create mode 100644 packages/twenty-server/src/engine/metadata-event-emitter/types/metadata-event-batch.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-event-emitter/utils/compute-metadata-event-name.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook-jobs-for-metadata.job.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/webhook/types/webhook-job-data.type.ts diff --git a/packages/twenty-front/src/modules/settings/components/SettingsDatabaseEventsForm.tsx b/packages/twenty-front/src/modules/settings/components/SettingsDatabaseEventsForm.tsx index 986ad16ca1..7fbfeb966b 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsDatabaseEventsForm.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsDatabaseEventsForm.tsx @@ -1,16 +1,10 @@ -import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { WebhookEntitySelect } from '@/settings/developers/components/WebhookEntitySelect'; import { Select } from '@/ui/input/components/Select'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; -import { - IconBox, - IconNorthStar, - IconPlus, - IconTrash, - useIcons, -} from 'twenty-ui/display'; +import { IconBox, IconNorthStar, IconPlus, IconTrash } from 'twenty-ui/display'; import { IconButton, type SelectOption } from 'twenty-ui/input'; const OBJECT_DROPDOWN_WIDTH = 240; @@ -51,19 +45,6 @@ export const SettingsDatabaseEventsForm = ({ }) => { const isMobile = useIsMobile(); - const { objectMetadataItems } = useObjectMetadataItems(); - - const { getIcon } = useIcons(); - - const objectOptions: SelectOption[] = [ - { label: t`All Objects`, value: '*', Icon: IconNorthStar }, - ...objectMetadataItems.map((item) => ({ - label: item.labelPlural, - value: item.nameSingular, - Icon: getIcon(item.icon), - })), - ]; - const getActionOptions = ( updatedFields?: string[], ): SelectOption[] => { @@ -86,15 +67,12 @@ export const SettingsDatabaseEventsForm = ({ <> {events.map((operation, index) => ( - ` + align-items: center; + background-color: ${({ theme }) => theme.background.transparent.lighter}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + box-sizing: border-box; + cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + height: ${({ theme }) => theme.spacing(8)}; + justify-content: space-between; + padding: 0 ${({ theme }) => theme.spacing(2)}; + width: 100%; + + &:hover { + background-color: ${({ theme, disabled }) => + disabled + ? theme.background.transparent.lighter + : theme.background.transparent.light}; + } +`; + +const StyledControlLabel = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.md}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const StyledControlIconChevronDown = styled(IconChevronDown)<{ + disabled?: boolean; +}>` + color: ${({ disabled, theme }) => + disabled ? theme.font.color.extraLight : theme.font.color.tertiary}; +`; + +type WebhookEntitySelectProps = { + value: string | null; + onChange: (value: string | null) => void; + disabled?: boolean; + dropdownId?: string; +}; + +export const WebhookEntitySelect = ({ + value, + onChange, + disabled = false, + dropdownId = WEBHOOK_ENTITY_DROPDOWN_ID, +}: WebhookEntitySelectProps) => { + const theme = useTheme(); + const [searchInput, setSearchInput] = useState(''); + const { objectMetadataItems } = useObjectMetadataItems(); + const { getIcon } = useIcons(); + const { closeDropdown } = useCloseDropdown(); + + const selectedItemId = useRecoilComponentValue( + selectedItemIdComponentState, + dropdownId, + ); + + const metadataOptions = [ + { label: t`All Metadata`, value: 'metadata.*', icon: IconNorthStar }, + { label: t`Object`, value: 'metadata.objectMetadata', icon: IconTable }, + { label: t`Field`, value: 'metadata.fieldMetadata', icon: IconBox }, + { label: t`View`, value: 'metadata.view', icon: IconEye }, + { label: t`View Field`, value: 'metadata.viewField', icon: IconEye }, + { label: t`Index`, value: 'metadata.index', icon: IconSettings }, + { label: t`Webhook`, value: 'metadata.webhook', icon: IconCode }, + ]; + + const objectOptions = [ + { label: t`All Objects`, value: '*', Icon: IconNorthStar }, + ...objectMetadataItems.map((item) => ({ + label: item.labelPlural, + value: item.nameSingular, + Icon: getIcon(item.icon), + })), + ]; + + const filteredObjectOptions = objectOptions.filter((option) => + option.label.toLowerCase().includes(searchInput.toLowerCase()), + ); + + const filteredMetadataOptions = metadataOptions.filter((option) => + option.label.toLowerCase().includes(searchInput.toLowerCase()), + ); + + // Find selected option label for display + const getSelectedLabel = () => { + if (!isDefined(value)) { + return t`Select entity`; + } + if (value === '*') { + return t`All Objects`; + } + + const metadataOption = metadataOptions.find((opt) => opt.value === value); + if (isDefined(metadataOption)) { + return metadataOption.label; + } + + const objectOption = objectOptions.find((opt) => opt.value === value); + if (isDefined(objectOption)) { + return objectOption.label; + } + + return value; + }; + + const handleSelect = (selectedValue: string) => { + if (disabled) return; + onChange(selectedValue); + closeDropdown(dropdownId); + setSearchInput(''); + }; + + const shouldShowObjects = filteredObjectOptions.length > 0; + const shouldShowMetadata = filteredMetadataOptions.length > 0; + const shouldShowSeparator = shouldShowObjects && shouldShowMetadata; + + const selectableItemIds = [ + ...filteredObjectOptions.map((opt) => opt.value), + ...filteredMetadataOptions.map((opt) => opt.value), + ]; + + return ( + setSearchInput('')} + clickableComponent={ + + {getSelectedLabel()} + + + } + dropdownComponents={ + + setSearchInput(event.target.value)} + /> + + + + {shouldShowObjects && ( + <> + + {filteredObjectOptions.map((option) => ( + handleSelect(option.value)} + > + handleSelect(option.value)} + /> + + ))} + + )} + {shouldShowSeparator && } + {shouldShowMetadata && ( + <> + + {filteredMetadataOptions.map((option) => ( + handleSelect(option.value)} + > + handleSelect(option.value)} + /> + + ))} + + )} + + + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/developers/hooks/__tests__/useWebhookForm.test.tsx b/packages/twenty-front/src/modules/settings/developers/hooks/__tests__/useWebhookForm.test.tsx index 9cea8accee..ea7698eff3 100644 --- a/packages/twenty-front/src/modules/settings/developers/hooks/__tests__/useWebhookForm.test.tsx +++ b/packages/twenty-front/src/modules/settings/developers/hooks/__tests__/useWebhookForm.test.tsx @@ -10,6 +10,7 @@ import { DELETE_WEBHOOK } from '@/settings/developers/graphql/mutations/deleteWe import { UPDATE_WEBHOOK } from '@/settings/developers/graphql/mutations/updateWebhook'; import { GET_WEBHOOK } from '@/settings/developers/graphql/queries/getWebhook'; import { useWebhookForm } from '@/settings/developers/hooks/useWebhookForm'; +import { WEBHOOK_EMPTY_OPERATION } from '~/pages/settings/developers/webhooks/constants/WebhookEmptyOperation'; const mockNavigateSettings = jest.fn(); const mockEnqueueSuccessSnackBar = jest.fn(); @@ -146,7 +147,7 @@ describe('useWebhookForm', () => { expect(result.current.formConfig.getValues()).toEqual({ targetUrl: '', description: '', - operations: [{ object: '*', action: '*' }], + operations: [{ object: '*', action: '*' }, WEBHOOK_EMPTY_OPERATION], secret: '', }); }); diff --git a/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookForm.ts b/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookForm.ts index e66bcf248c..47d51b8eff 100644 --- a/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookForm.ts +++ b/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookForm.ts @@ -32,7 +32,7 @@ type UseWebhookFormProps = { const DEFAULT_FORM_VALUES: WebhookFormValues = { targetUrl: '', description: '', - operations: [{ object: '*', action: '*' }], + operations: addEmptyOperationIfNecessary([{ object: '*', action: '*' }]), secret: '', }; diff --git a/packages/twenty-front/src/modules/settings/developers/utils/__tests__/addEmptyOperationIfNecessary.test.ts b/packages/twenty-front/src/modules/settings/developers/utils/__tests__/addEmptyOperationIfNecessary.test.ts index 21d966830c..dbe97e12cf 100644 --- a/packages/twenty-front/src/modules/settings/developers/utils/__tests__/addEmptyOperationIfNecessary.test.ts +++ b/packages/twenty-front/src/modules/settings/developers/utils/__tests__/addEmptyOperationIfNecessary.test.ts @@ -18,7 +18,7 @@ describe('addEmptyOperationIfNecessary', () => { ]); }); - it('should not add empty operation when wildcard operation exists', () => { + it('should add empty operation when only record wildcard operation exists', () => { const operations: WebhookOperationType[] = [ { object: '*', action: '*' }, { object: 'person', action: 'created' }, @@ -29,6 +29,36 @@ describe('addEmptyOperationIfNecessary', () => { expect(result).toEqual([ { object: '*', action: '*' }, { object: 'person', action: 'created' }, + WEBHOOK_EMPTY_OPERATION, + ]); + }); + + it('should add empty operation when only metadata wildcard operation exists', () => { + const operations: WebhookOperationType[] = [ + { object: 'metadata.*', action: '*' }, + { object: 'person', action: 'created' }, + ]; + + const result = addEmptyOperationIfNecessary(operations); + + expect(result).toEqual([ + { object: 'metadata.*', action: '*' }, + { object: 'person', action: 'created' }, + WEBHOOK_EMPTY_OPERATION, + ]); + }); + + it('should not add empty operation when both record and metadata wildcard operations exist', () => { + const operations: WebhookOperationType[] = [ + { object: '*', action: '*' }, + { object: 'metadata.*', action: '*' }, + ]; + + const result = addEmptyOperationIfNecessary(operations); + + expect(result).toEqual([ + { object: '*', action: '*' }, + { object: 'metadata.*', action: '*' }, ]); }); diff --git a/packages/twenty-front/src/modules/settings/developers/utils/__tests__/parseOperationsFromStrings.test.ts b/packages/twenty-front/src/modules/settings/developers/utils/__tests__/parseOperationsFromStrings.test.ts index ce5230625a..d5329eea62 100644 --- a/packages/twenty-front/src/modules/settings/developers/utils/__tests__/parseOperationsFromStrings.test.ts +++ b/packages/twenty-front/src/modules/settings/developers/utils/__tests__/parseOperationsFromStrings.test.ts @@ -13,14 +13,36 @@ describe('parseOperationsFromStrings', () => { ]); }); - it('should handle wildcard operations', () => { - const operations = ['*.*', 'person.created']; + it('should handle wildcard operations across objects and metadata', () => { + const operations = ['*.*', 'metadata.*.*']; const result = parseOperationsFromStrings(operations); expect(result).toEqual([ { object: '*', action: '*' }, + { object: 'metadata.*', action: '*' }, + ]); + }); + + it('should handle mixed object and metadata entity operations', () => { + const operations = ['person.created', 'metadata.objectMetadata.created']; + + const result = parseOperationsFromStrings(operations); + + expect(result).toEqual([ { object: 'person', action: 'created' }, + { object: 'metadata.objectMetadata', action: 'created' }, + ]); + }); + + it('should handle wildcard and specific operations together', () => { + const operations = ['*.*', 'metadata.objectMetadata.updated']; + + const result = parseOperationsFromStrings(operations); + + expect(result).toEqual([ + { object: '*', action: '*' }, + { object: 'metadata.objectMetadata', action: 'updated' }, ]); }); diff --git a/packages/twenty-front/src/modules/settings/developers/utils/addEmptyOperationIfNecessary.ts b/packages/twenty-front/src/modules/settings/developers/utils/addEmptyOperationIfNecessary.ts index ba4ac3140b..b6a3f508fa 100644 --- a/packages/twenty-front/src/modules/settings/developers/utils/addEmptyOperationIfNecessary.ts +++ b/packages/twenty-front/src/modules/settings/developers/utils/addEmptyOperationIfNecessary.ts @@ -4,11 +4,26 @@ import { type WebhookOperationType } from '~/pages/settings/developers/webhooks/ export const addEmptyOperationIfNecessary = ( newOperations: WebhookOperationType[], ): WebhookOperationType[] => { - if ( - !newOperations.some((op) => op.object === '*' && op.action === '*') && - !newOperations.some((op) => op.object === null) - ) { - return [...newOperations, WEBHOOK_EMPTY_OPERATION]; + const emptyOperationIndex = newOperations.findIndex( + (op) => op.object === null, + ); + const hasEmptyOperation = emptyOperationIndex !== -1; + const nonEmptyOperations = newOperations.filter((op) => op.object !== null); + const hasRecordCatchAll = nonEmptyOperations.some( + (op) => op.object === '*' && op.action === '*', + ); + const hasMetadataCatchAll = nonEmptyOperations.some( + (op) => op.object === 'metadata.*' && op.action === '*', + ); + + if (hasRecordCatchAll && hasMetadataCatchAll) { + return nonEmptyOperations; } - return newOperations; + + if (hasEmptyOperation) { + const emptyOperation = newOperations[emptyOperationIndex]; + return [...nonEmptyOperations, emptyOperation]; + } + + return [...nonEmptyOperations, WEBHOOK_EMPTY_OPERATION]; }; diff --git a/packages/twenty-front/src/modules/settings/developers/utils/parseOperationsFromStrings.ts b/packages/twenty-front/src/modules/settings/developers/utils/parseOperationsFromStrings.ts index ce2ded42b0..b68b80aaee 100644 --- a/packages/twenty-front/src/modules/settings/developers/utils/parseOperationsFromStrings.ts +++ b/packages/twenty-front/src/modules/settings/developers/utils/parseOperationsFromStrings.ts @@ -4,7 +4,17 @@ export const parseOperationsFromStrings = ( operations: string[], ): WebhookOperationType[] => { return operations.map((op: string) => { - const [object, action] = op.split('.'); + const parts = op.split('.'); + + if (parts[0] === 'metadata' && parts.length === 3) { + return { + object: `${parts[0]}.${parts[1]}`, + action: parts[2], + }; + } + + const [object, action] = parts; + return { object, action }; }); }; diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 5f3e6eea19..08dc7e0b0f 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -63,6 +63,7 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { RowLevelPermissionModule } from 'src/engine/metadata-modules/row-level-permission-predicate/row-level-permission.module'; import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module'; +import { MetadataEventEmitterModule } from 'src/engine/metadata-event-emitter/metadata-event-emitter.module'; import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module'; import { ChannelSyncModule } from 'src/modules/connected-account/channel-sync/channel-sync.module'; import { DashboardModule } from 'src/modules/dashboard/dashboard.module'; @@ -104,6 +105,7 @@ import { FileModule } from './file/file.module'; PostgresCredentialsModule, WorkflowApiModule, WorkspaceEventEmitterModule, + MetadataEventEmitterModule, ActorModule, TelemetryModule, AdminPanelModule, diff --git a/packages/twenty-server/src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener.ts b/packages/twenty-server/src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener.ts new file mode 100644 index 0000000000..29fa5d72da --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +import { AllMetadataName } from 'twenty-shared/metadata'; + +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type'; +import { CallWebhookJobsForMetadataJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook-jobs-for-metadata.job'; +import { AllMetadataEventType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; + +@Injectable() +export class MetadataEventsToDbListener { + constructor( + @InjectMessageQueue(MessageQueue.webhookQueue) + private readonly webhookQueueService: MessageQueueService, + ) {} + + @OnEvent('metadata.*.created') + async handleCreate( + metadataEventBatch: MetadataEventBatch, + ): Promise { + return this.handleEvent(metadataEventBatch); + } + + @OnEvent('metadata.*.updated') + async handleUpdate( + metadataEventBatch: MetadataEventBatch, + ): Promise { + return this.handleEvent(metadataEventBatch); + } + + @OnEvent('metadata.*.deleted') + async handleDelete( + metadataEventBatch: MetadataEventBatch, + ): Promise { + return this.handleEvent(metadataEventBatch); + } + + private async handleEvent( + metadataEventBatch: MetadataEventBatch< + AllMetadataName, + AllMetadataEventType + >, + ): Promise { + await this.webhookQueueService.add< + MetadataEventBatch + >(CallWebhookJobsForMetadataJob.name, metadataEventBatch, { + retryLimit: 3, + }); + } +} diff --git a/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.module.ts b/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.module.ts new file mode 100644 index 0000000000..f7a9d1993a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; + +import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter'; +import { MetadataEventsToDbListener } from 'src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener'; + +@Global() +@Module({ + providers: [MetadataEventEmitter, MetadataEventsToDbListener], + exports: [MetadataEventEmitter], +}) +export class MetadataEventEmitterModule {} diff --git a/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.ts b/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.ts new file mode 100644 index 0000000000..16edb98c1f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-event-emitter/metadata-event-emitter.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +import { AllMetadataName } from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; + +import { getWorkspaceAuthContext } from 'src/engine/core-modules/auth/storage/workspace-auth-context.storage'; +import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; +import { MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type'; +import { computeMetadataEventName } from 'src/engine/metadata-event-emitter/utils/compute-metadata-event-name.util'; +import { + AllMetadataEventName, + AllMetadataEventType, + MetadataEvent, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; + +type EmitMetadataEventsArgs = { + metadataEvents: MetadataEvent[]; + workspaceId: string; + initiatorContext?: WorkspaceAuthContext; +}; + +@Injectable() +export class MetadataEventEmitter { + constructor(private readonly eventEmitter: EventEmitter2) {} + + public emitMetadataEvents({ + metadataEvents, + workspaceId, + initiatorContext, + }: EmitMetadataEventsArgs): void { + if (metadataEvents.length === 0) { + return; + } + + const resolvedInitiatorContext = + this.resolveInitiatorContext(initiatorContext); + + const userId = + resolvedInitiatorContext?.type === 'user' || + resolvedInitiatorContext?.type === 'pendingActivationUser' + ? resolvedInitiatorContext.user.id + : undefined; + const apiKeyId = + resolvedInitiatorContext?.type === 'apiKey' + ? resolvedInitiatorContext.apiKey.id + : undefined; + + const grouped = this.groupByMetadataNameAndAction(metadataEvents); + + for (const { eventName, events, metadataName, type } of grouped.values()) { + if (metadataEvents.length === 0) { + continue; + } + + const metadataEventBatch: MetadataEventBatch = { + name: eventName, + workspaceId, + metadataName, + type, + events, + userId, + apiKeyId, + }; + + this.eventEmitter.emit(eventName, metadataEventBatch); + } + } + + private resolveInitiatorContext( + initiatorContext?: WorkspaceAuthContext, + ): WorkspaceAuthContext | undefined { + if (isDefined(initiatorContext)) { + return initiatorContext; + } + + try { + return getWorkspaceAuthContext(); + } catch { + return undefined; + } + } + + private groupByMetadataNameAndAction(metadataEvents: MetadataEvent[]) { + const grouped = new Map< + AllMetadataEventName, + { + metadataName: AllMetadataName; + type: AllMetadataEventType; + eventName: AllMetadataEventName; + events: MetadataEvent[]; + } + >(); + + for (const metadataEvent of metadataEvents) { + const { metadataName, type } = metadataEvent; + const eventName = computeMetadataEventName({ + metadataName, + type, + }); + const occurence = grouped.get(eventName); + + if (!isDefined(occurence)) { + grouped.set(eventName, { + eventName, + metadataName, + type, + events: [metadataEvent], + }); + continue; + } + + grouped.set(eventName, { + ...occurence, + events: [...occurence.events, metadataEvent], + }); + } + + return grouped; + } +} diff --git a/packages/twenty-server/src/engine/metadata-event-emitter/types/metadata-event-batch.type.ts b/packages/twenty-server/src/engine/metadata-event-emitter/types/metadata-event-batch.type.ts new file mode 100644 index 0000000000..3d7fdfc779 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-event-emitter/types/metadata-event-batch.type.ts @@ -0,0 +1,19 @@ +import type { AllMetadataName } from 'twenty-shared/metadata'; + +import { + type AllMetadataEventType, + type MetadataEvent, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; + +export type MetadataEventBatch< + TMetadataName extends AllMetadataName = AllMetadataName, + TType extends AllMetadataEventType = AllMetadataEventType, +> = { + name: `metadata.${TMetadataName}.${TType}`; + workspaceId: string; + metadataName: TMetadataName; + type: TType; + events: MetadataEvent[]; + userId?: string; + apiKeyId?: string; +}; diff --git a/packages/twenty-server/src/engine/metadata-event-emitter/utils/compute-metadata-event-name.util.ts b/packages/twenty-server/src/engine/metadata-event-emitter/utils/compute-metadata-event-name.util.ts new file mode 100644 index 0000000000..7bd3d135d7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-event-emitter/utils/compute-metadata-event-name.util.ts @@ -0,0 +1,7 @@ +import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; + +export const computeMetadataEventName = ({ + metadataName, + type, +}: Pick) => + `metadata.${metadataName}.${type}` as const; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts index 059cca64a9..35fac9b727 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts @@ -1,8 +1,8 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; import { Args, Context, Mutation, Parent, ResolveField } from '@nestjs/graphql'; -import { isDefined } from 'twenty-shared/utils'; import { PermissionFlagType } from 'twenty-shared/constants'; +import { isDefined } from 'twenty-shared/utils'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook-jobs-for-metadata.job.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook-jobs-for-metadata.job.ts new file mode 100644 index 0000000000..9261cafab8 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook-jobs-for-metadata.job.ts @@ -0,0 +1,99 @@ +import chunk from 'lodash.chunk'; +import { isDefined } from 'twenty-shared/utils'; + +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type'; +import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type'; +import { CallWebhookJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook.job'; +import { type CallMetadataWebhookJobData } from 'src/engine/metadata-modules/webhook/types/webhook-job-data.type'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +const WEBHOOK_JOBS_CHUNK_SIZE = 20; + +@Processor(MessageQueue.webhookQueue) +export class CallWebhookJobsForMetadataJob { + constructor( + @InjectMessageQueue(MessageQueue.webhookQueue) + private readonly messageQueueService: MessageQueueService, + private readonly workspaceCacheService: WorkspaceCacheService, + ) {} + + @Process(CallWebhookJobsForMetadataJob.name) + async handle(metadataEventBatch: MetadataEventBatch): Promise { + const eventName = metadataEventBatch.name; + const metadataName = metadataEventBatch.metadataName; + const operation = metadataEventBatch.type; + + const operationsToMatch = [ + eventName, + `metadata.${metadataName}.*`, + `metadata.*.${operation}`, + `metadata.*.*`, + '*.*', + ]; + + const { flatWebhookMaps } = await this.workspaceCacheService.getOrRecompute( + metadataEventBatch.workspaceId, + ['flatWebhookMaps'], + ); + + const webhooks = Object.values(flatWebhookMaps.byUniversalIdentifier) + .filter(isDefined) + .filter((webhook) => + operationsToMatch.some((operationToMatch) => + webhook.operations.includes(operationToMatch), + ), + ); + + if (webhooks.length === 0) { + return; + } + + const webhookEvents = this.transformMetadataEventBatchToWebhookEvents({ + metadataEventBatch, + webhooks, + }); + + const webhookEventsChunks = chunk(webhookEvents, WEBHOOK_JOBS_CHUNK_SIZE); + + for (const webhookEventsChunk of webhookEventsChunks) { + await this.messageQueueService.add( + CallWebhookJob.name, + webhookEventsChunk, + { retryLimit: 3 }, + ); + } + } + + private transformMetadataEventBatchToWebhookEvents({ + metadataEventBatch, + webhooks, + }: { + metadataEventBatch: MetadataEventBatch; + webhooks: FlatWebhook[]; + }): CallMetadataWebhookJobData[] { + const result: CallMetadataWebhookJobData[] = []; + + for (const webhook of webhooks) { + for (const event of metadataEventBatch.events) { + result.push({ + targetUrl: webhook.targetUrl, + eventName: metadataEventBatch.name, + workspaceId: metadataEventBatch.workspaceId, + webhookId: webhook.id, + eventDate: new Date(), + userId: metadataEventBatch.userId, + apiKeyId: metadataEventBatch.apiKeyId, + secret: webhook.secret, + event, + }); + } + } + + return result; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook-jobs.job.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook-jobs.job.ts index d93fd2aa00..718830660c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook-jobs.job.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook-jobs.job.ts @@ -10,10 +10,8 @@ import { Process } from 'src/engine/core-modules/message-queue/decorators/proces import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; -import { - CallWebhookJob, - type CallWebhookJobData, -} from 'src/engine/metadata-modules/webhook/jobs/call-webhook.job'; +import { CallWebhookJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook.job'; +import { type CallWebhookJobData } from 'src/engine/metadata-modules/webhook/types/webhook-job-data.type'; import { transformEventBatchToWebhookEvents } from 'src/engine/metadata-modules/webhook/utils/transform-event-batch-to-webhook-events'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook.job.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook.job.ts index cfa3ae74c1..2f07969bde 100644 --- a/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook.job.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook.job.ts @@ -10,21 +10,7 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; - -export type CallWebhookJobData = { - targetUrl: string; - eventName: string; - objectMetadata: { id: string; nameSingular: string }; - workspaceId: string; - webhookId: string; - eventDate: Date; - userId?: string; - workspaceMemberId?: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - record: any; - updatedFields?: string[]; - secret?: string; -}; +import { type WebhookJobData } from 'src/engine/metadata-modules/webhook/types/webhook-job-data.type'; @Processor(MessageQueue.webhookQueue) export class CallWebhookJob { @@ -35,7 +21,7 @@ export class CallWebhookJob { ) {} private generateSignature( - payload: CallWebhookJobData, + payload: Record, secret: string, timestamp: string, ): string { @@ -46,7 +32,7 @@ export class CallWebhookJob { } @Process(CallWebhookJob.name) - async handle(webhookJobEvents: CallWebhookJobData[]): Promise { + async handle(webhookJobEvents: WebhookJobData[]): Promise { await Promise.all( webhookJobEvents.map( async (webhookJobEvent) => await this.callWebhook(webhookJobEvent), @@ -54,7 +40,7 @@ export class CallWebhookJob { ); } - private async callWebhook(data: CallWebhookJobData): Promise { + private async callWebhook(data: WebhookJobData): Promise { const commonPayload = { url: data.targetUrl, webhookId: data.webhookId, diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/webhook-job.module.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/webhook-job.module.ts index f8976544b9..8ca4725b2b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/webhook-job.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/webhook-job.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; +import { CallWebhookJobsForMetadataJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook-jobs-for-metadata.job'; import { CallWebhookJobsJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook-jobs.job'; import { CallWebhookJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook.job'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @@ -14,6 +15,10 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache SecureHttpClientModule, WorkspaceCacheModule, ], - providers: [CallWebhookJobsJob, CallWebhookJob], + providers: [ + CallWebhookJobsJob, + CallWebhookJobsForMetadataJob, + CallWebhookJob, + ], }) export class WebhookJobModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/types/webhook-job-data.type.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/types/webhook-job-data.type.ts new file mode 100644 index 0000000000..55ee5ff97a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/types/webhook-job-data.type.ts @@ -0,0 +1,27 @@ +import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; + +type WebhookJobBase = { + targetUrl: string; + eventName: string; + workspaceId: string; + webhookId: string; + eventDate: Date; + userId?: string; + apiKeyId?: string; + secret?: string; +}; + +export type CallWebhookJobData = WebhookJobBase & { + objectMetadata: { id: string; nameSingular: string }; + workspaceMemberId?: string; + applicationId?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + record: any; + updatedFields?: string[]; +}; + +export type CallMetadataWebhookJobData = WebhookJobBase & { + event: MetadataEvent; +}; + +export type WebhookJobData = CallWebhookJobData | CallMetadataWebhookJobData; diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/utils/transform-event-batch-to-webhook-events.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/transform-event-batch-to-webhook-events.ts index fcbe34879b..dfe8c666ca 100644 --- a/packages/twenty-server/src/engine/metadata-modules/webhook/utils/transform-event-batch-to-webhook-events.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/transform-event-batch-to-webhook-events.ts @@ -1,7 +1,7 @@ import type { ObjectRecordEvent } from 'twenty-shared/database-events'; import { type WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity'; -import { type CallWebhookJobData } from 'src/engine/metadata-modules/webhook/jobs/call-webhook.job'; +import { type CallWebhookJobData } from 'src/engine/metadata-modules/webhook/types/webhook-job-data.type'; import { transformEventToWebhookEvent } from 'src/engine/metadata-modules/webhook/utils/transform-event-to-webhook-event'; import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.service.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.service.ts index 31213d332b..6ae2019f43 100644 --- a/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.service.ts @@ -4,8 +4,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; import { IsNull, Repository } from 'typeorm'; -import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { fromCreateWebhookInputToFlatWebhookToCreate } from 'src/engine/metadata-modules/flat-webhook/utils/from-create-webhook-input-to-flat-webhook-to-create.util'; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service.ts index a278e91166..fe5a298dd8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service.ts @@ -7,6 +7,7 @@ import { import { isDefined } from 'twenty-shared/utils'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter'; import { ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant'; import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; @@ -53,6 +54,7 @@ export class WorkspaceMigrationValidateBuildAndRunService { private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, private readonly workspaceMigrationBuildOrchestratorService: WorkspaceMigrationBuildOrchestratorService, private readonly workspaceCacheService: WorkspaceCacheService, + private readonly metadataEventEmitter: MetadataEventEmitter, twentyConfigService: TwentyConfigService, ) { const logLevels = twentyConfigService.get('LOG_LEVELS'); @@ -248,7 +250,13 @@ export class WorkspaceMigrationValidateBuildAndRunService { }) : validateAndBuildResult.workspaceMigration; - await this.workspaceMigrationRunnerService.run(workspaceMigration); + const { metadataEvents } = + await this.workspaceMigrationRunnerService.run(workspaceMigration); + + this.metadataEventEmitter.emitMetadataEvents({ + metadataEvents, + workspaceId: args.workspaceId, + }); } public async validateBuildAndRunWorkspaceMigration({ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common.ts index 25460a262e..cebb48740a 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common.ts @@ -9,12 +9,14 @@ import { export type AllUniversalWorkspaceMigrationAction< TActionType extends WorkspaceMigrationActionType = WorkspaceMigrationActionType, -> = MetadataUniversalWorkspaceMigrationAction; + TMetadataName extends AllMetadataName = AllMetadataName, +> = MetadataUniversalWorkspaceMigrationAction; export type AllFlatWorkspaceMigrationAction< TActionType extends WorkspaceMigrationActionType = WorkspaceMigrationActionType, -> = MetadataFlatWorkspaceMigrationAction; + TMetadataName extends AllMetadataName = AllMetadataName, +> = MetadataFlatWorkspaceMigrationAction; export { WorkspaceMigrationActionType }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface.ts index 078b44ad52..f204b618c6 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface.ts @@ -6,7 +6,6 @@ import { QueryRunner } from 'typeorm'; import { LoggerService } from 'src/engine/core-modules/logger/logger.service'; import { ALL_METADATA_ENTITY_BY_METADATA_NAME } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; -import { AllFlatEntityTypesByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name'; import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type'; import { MetadataRelatedFlatEntityMapsKeys } from 'src/engine/metadata-modules/flat-entity/types/metadata-related-flat-entity-maps-keys.type'; @@ -61,9 +60,15 @@ export abstract class BaseWorkspaceMigrationRunnerActionHandlerService< TActionType extends WorkspaceMigrationActionType, TMetadataName extends AllMetadataName, TUniversalAction extends // TODO create abstracted type utils - AllUniversalWorkspaceMigrationAction = AllFlatEntityTypesByMetadataName[TMetadataName]['universalActions'][TActionType], + AllUniversalWorkspaceMigrationAction = AllUniversalWorkspaceMigrationAction< + TActionType, + TMetadataName + >, TFlatAction extends - AllFlatWorkspaceMigrationAction = AllFlatEntityTypesByMetadataName[TMetadataName]['flatActions'][TActionType], + AllFlatWorkspaceMigrationAction = AllFlatWorkspaceMigrationAction< + TActionType, + TMetadataName + >, > { public actionType: TActionType; public metadataName: TMetadataName; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts index a7c13ccc8c..73471e0298 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts @@ -8,7 +8,6 @@ import { DataSource } from 'typeorm'; import { LoggerService } from 'src/engine/core-modules/logger/logger.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; -import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util'; import { getMetadataSerializedRelationNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-serialized-relation-names.util'; @@ -22,6 +21,7 @@ import { WorkspaceMigrationRunnerExceptionCode, } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception'; import { WorkspaceMigrationRunnerActionHandlerRegistryService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/registry/workspace-migration-runner-action-handler-registry.service'; +import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; @Injectable() export class WorkspaceMigrationRunnerService { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event.ts index 7e8ef374e3..943ecdb101 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event.ts @@ -5,6 +5,7 @@ import { type MetadataUniversalFlatEntityPropertiesToCompare } from 'src/engine/ type BaseMetadataEvent = { metadataName: T; + recordId: string; properties: TPayload; }; @@ -12,7 +13,7 @@ export type DeleteMetadataEvent = BaseMetadataEvent< T, { before: MetadataFlatEntity } > & { - type: 'delete'; + type: 'deleted'; }; export type UpdateMetadataEventDiff< @@ -37,16 +38,21 @@ export type UpdateMetadataEvent< before: MetadataFlatEntity; after: MetadataFlatEntity; } -> & { type: 'update' }; +> & { type: 'updated' }; export type CreateMetadataEvent = BaseMetadataEvent< T, { after: MetadataFlatEntity; } -> & { type: 'create' }; +> & { type: 'created' }; export type MetadataEvent = | DeleteMetadataEvent | UpdateMetadataEvent | CreateMetadataEvent; + +export type AllMetadataEventType = MetadataEvent['type']; + +export type AllMetadataEventName = + `metadata.${AllMetadataName}.${MetadataEvent['type']}`; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts index 7af7c4b47d..1da0150e3c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts @@ -13,7 +13,8 @@ export const deriveMetadataEventsFromCreateAction = ( case 'fieldMetadata': { return flatAction.flatFieldMetadatas.map( (flatFieldMetadata): CreateMetadataEvent<'fieldMetadata'> => ({ - type: 'create', + type: 'created', + recordId: flatFieldMetadata.id, metadataName: 'fieldMetadata', properties: { after: flatFieldMetadata, @@ -23,8 +24,9 @@ export const deriveMetadataEventsFromCreateAction = ( } case 'objectMetadata': { const objectEvent: CreateMetadataEvent<'objectMetadata'> = { - type: 'create', + type: 'created', metadataName: 'objectMetadata', + recordId: flatAction.flatEntity.id, properties: { after: flatAction.flatEntity, }, @@ -32,7 +34,8 @@ export const deriveMetadataEventsFromCreateAction = ( const fieldEvents: MetadataEvent[] = flatAction.flatFieldMetadatas.map( (flatFieldMetadata): CreateMetadataEvent<'fieldMetadata'> => ({ - type: 'create', + type: 'created', + recordId: flatFieldMetadata.id, metadataName: 'fieldMetadata', properties: { after: flatFieldMetadata, @@ -65,7 +68,8 @@ export const deriveMetadataEventsFromCreateAction = ( case 'webhook': { return [ { - type: 'create', + type: 'created', + recordId: flatAction.flatEntity.id, metadataName: flatAction.metadataName, properties: { after: flatAction.flatEntity, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts index ca46100eb9..65dfed1ee2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts @@ -52,8 +52,9 @@ export const deriveMetadataEventsFromDeleteAction = ({ return [ { - type: 'delete', + type: 'deleted', metadataName: flatAction.metadataName, + recordId: flatAction.entityId, properties: { before: flatEntityToDelete, }, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts index 6e51a8a6bb..91a56e8040 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts @@ -8,11 +8,11 @@ import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-e import { type MetadataUniversalFlatEntityPropertiesToCompare } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-properties-to-compare.type'; import { type AllFlatWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common'; import { - type UpdateMetadataEventDiff, type CreateMetadataEvent, type DeleteMetadataEvent, type MetadataEvent, type UpdateMetadataEvent, + type UpdateMetadataEventDiff, } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event'; export type DeriveMetadataEventsFromUpdateActionArgs = { @@ -42,8 +42,9 @@ const buildUpdateMetadataEvent = ({ ) as UpdateMetadataEventDiff; return { - type: 'update', + type: 'updated', metadataName, + recordId: before.id, properties: { updatedFields, diff, @@ -68,18 +69,20 @@ export const deriveMetadataEventsFromUpdateAction = ({ const deleteIndexMetadataEvent: DeleteMetadataEvent<'index'> = { metadataName: 'index', + recordId: fromFlatEntity.id, properties: { before: fromFlatEntity, }, - type: 'delete', + type: 'deleted', }; const createIndexMetadataEvent: CreateMetadataEvent<'index'> = { metadataName: 'index', + recordId: toFlatEntity.id, properties: { after: toFlatEntity, }, - type: 'create', + type: 'created', }; return [deleteIndexMetadataEvent, createIndexMetadataEvent];