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:
+31
-1
@@ -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: '*' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
+24
-2
@@ -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' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
+21
-6
@@ -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];
|
||||
};
|
||||
|
||||
+11
-1
@@ -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 };
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user