feat: emit metadata events for schema changes with actor context for webhooks (#17622)
## 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 <felix.malfait@gmail.com>
Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
+9
-1
@@ -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({
|
||||
|
||||
+4
-2
@@ -9,12 +9,14 @@ import {
|
||||
export type AllUniversalWorkspaceMigrationAction<
|
||||
TActionType extends
|
||||
WorkspaceMigrationActionType = WorkspaceMigrationActionType,
|
||||
> = MetadataUniversalWorkspaceMigrationAction<AllMetadataName, TActionType>;
|
||||
TMetadataName extends AllMetadataName = AllMetadataName,
|
||||
> = MetadataUniversalWorkspaceMigrationAction<TMetadataName, TActionType>;
|
||||
|
||||
export type AllFlatWorkspaceMigrationAction<
|
||||
TActionType extends
|
||||
WorkspaceMigrationActionType = WorkspaceMigrationActionType,
|
||||
> = MetadataFlatWorkspaceMigrationAction<AllMetadataName, TActionType>;
|
||||
TMetadataName extends AllMetadataName = AllMetadataName,
|
||||
> = MetadataFlatWorkspaceMigrationAction<TMetadataName, TActionType>;
|
||||
|
||||
export { WorkspaceMigrationActionType };
|
||||
|
||||
|
||||
+8
-3
@@ -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;
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+9
-3
@@ -5,6 +5,7 @@ import { type MetadataUniversalFlatEntityPropertiesToCompare } from 'src/engine/
|
||||
|
||||
type BaseMetadataEvent<T extends AllMetadataName, TPayload extends object> = {
|
||||
metadataName: T;
|
||||
recordId: string;
|
||||
properties: TPayload;
|
||||
};
|
||||
|
||||
@@ -12,7 +13,7 @@ export type DeleteMetadataEvent<T extends AllMetadataName> = BaseMetadataEvent<
|
||||
T,
|
||||
{ before: MetadataFlatEntity<T> }
|
||||
> & {
|
||||
type: 'delete';
|
||||
type: 'deleted';
|
||||
};
|
||||
|
||||
export type UpdateMetadataEventDiff<
|
||||
@@ -37,16 +38,21 @@ export type UpdateMetadataEvent<
|
||||
before: MetadataFlatEntity<T>;
|
||||
after: MetadataFlatEntity<T>;
|
||||
}
|
||||
> & { type: 'update' };
|
||||
> & { type: 'updated' };
|
||||
|
||||
export type CreateMetadataEvent<T extends AllMetadataName> = BaseMetadataEvent<
|
||||
T,
|
||||
{
|
||||
after: MetadataFlatEntity<T>;
|
||||
}
|
||||
> & { type: 'create' };
|
||||
> & { type: 'created' };
|
||||
|
||||
export type MetadataEvent<T extends AllMetadataName = AllMetadataName> =
|
||||
| DeleteMetadataEvent<T>
|
||||
| UpdateMetadataEvent<T>
|
||||
| CreateMetadataEvent<T>;
|
||||
|
||||
export type AllMetadataEventType = MetadataEvent['type'];
|
||||
|
||||
export type AllMetadataEventName =
|
||||
`metadata.${AllMetadataName}.${MetadataEvent['type']}`;
|
||||
|
||||
+8
-4
@@ -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,
|
||||
|
||||
+2
-1
@@ -52,8 +52,9 @@ export const deriveMetadataEventsFromDeleteAction = ({
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'delete',
|
||||
type: 'deleted',
|
||||
metadataName: flatAction.metadataName,
|
||||
recordId: flatAction.entityId,
|
||||
properties: {
|
||||
before: flatEntityToDelete,
|
||||
},
|
||||
|
||||
+7
-4
@@ -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 = <TMetadataName extends AllMetadataName>({
|
||||
) as UpdateMetadataEventDiff<TMetadataName, (typeof updatedFields)[number]>;
|
||||
|
||||
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];
|
||||
|
||||
Reference in New Issue
Block a user