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:
Abdullah.
2026-02-12 22:14:25 +05:00
committed by GitHub
parent f6b7ab2251
commit 13c2234856
30 changed files with 731 additions and 82 deletions
@@ -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: '*' },
]);
});
@@ -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' },
]);
});
@@ -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];
};
@@ -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 };
});
};